From d93acb25f421bf619530d225e6dbbb22516fbfb2 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 3 Jun 2024 09:21:02 -0700 Subject: [PATCH 01/24] hide top border for mentions and replies (#4330) --- src/view/com/notifications/FeedItem.tsx | 1 + src/view/com/post/Post.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 22ebf8271c..d6c38ea61c 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -148,6 +148,7 @@ let FeedItem = ({ borderColor: pal.colors.unreadNotifBorder, } } + hideTopBorder={hideTopBorder} /> ) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index a7ccf0be2b..51a1381ec8 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -41,10 +41,12 @@ import hairlineWidth = StyleSheet.hairlineWidth export function Post({ post, showReplyLine, + hideTopBorder, style, }: { post: AppBskyFeedDefs.PostView showReplyLine?: boolean + hideTopBorder?: boolean style?: StyleProp }) { const moderationOpts = useModerationOpts() @@ -82,6 +84,7 @@ export function Post({ richText={richText} moderation={moderation} showReplyLine={showReplyLine} + hideTopBorder={hideTopBorder} style={style} /> ) @@ -95,6 +98,7 @@ function PostInner({ richText, moderation, showReplyLine, + hideTopBorder, style, }: { post: Shadow @@ -102,6 +106,7 @@ function PostInner({ richText: RichTextAPI moderation: ModerationDecision showReplyLine?: boolean + hideTopBorder?: boolean style?: StyleProp }) { const queryClient = useQueryClient() @@ -143,7 +148,12 @@ function PostInner({ return ( {showReplyLine && } @@ -243,7 +253,6 @@ const styles = StyleSheet.create({ paddingRight: 15, paddingBottom: 5, paddingLeft: 10, - borderTopWidth: hairlineWidth, // @ts-ignore web only -prf cursor: 'pointer', }, From de257a11869292953144da956b05b8e7cc276991 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 3 Jun 2024 17:05:14 -0500 Subject: [PATCH 02/24] =?UTF-8?q?Revert=20"[=F0=9F=90=B4]=20Embed=20backwa?= =?UTF-8?q?rds=20compat=20(#4302)"=20(#4338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f868821cfcc87b62a320e5a1e11375fdb973adc1. --- src/components/dms/MessageItemEmbed.tsx | 4 +- .../Messages/Conversation/MessagesList.tsx | 45 ++++++++++++++++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index 9deb0c1d91..5d3656bac1 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -2,7 +2,6 @@ import React from 'react' import {View} from 'react-native' import {AppBskyEmbedRecord} from '@atproto/api' -import {isNative} from '#/platform/detection' import {PostEmbeds} from '#/view/com/util/post-embeds' import {atoms as a, useTheme} from '#/alf' @@ -14,8 +13,7 @@ let MessageItemEmbed = ({ const t = useTheme() return ( - + ) diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index de77997f1d..e6f657b497 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -13,9 +13,13 @@ import { } from 'react-native-reanimated' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {AppBskyEmbedRecord, RichText} from '@atproto/api' +import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' +import { + convertBskyAppUrlIfNeeded, + isBskyPostUrl, +} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {isConvoActive, useConvoActive} from '#/state/messages/convo' @@ -289,6 +293,45 @@ export function MessagesList({ cid: post.cid, }, } + + // look for the embed uri in the facets, so we can remove it from the text + const postLinkFacet = rt.facets?.find(facet => { + return facet.features.find(feature => { + if (AppBskyRichtextFacet.isLink(feature)) { + if (isBskyPostUrl(feature.uri)) { + const url = convertBskyAppUrlIfNeeded(feature.uri) + const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) + + // this might have a handle instead of a DID + // so just compare the rkey - not particularly dangerous + return post.uri.endsWith(rkey) + } + } + return false + }) + }) + + if (postLinkFacet) { + // remove the post link from the text + rt.delete( + postLinkFacet.index.byteStart, + postLinkFacet.index.byteEnd, + ) + + // 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}, + ) + } + } } } catch (error) { logger.error('Failed to get post as quote for DM', {error}) From f05aebf78e816aa06a98fb0f826b7164775b3cc4 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 3 Jun 2024 15:05:37 -0700 Subject: [PATCH 03/24] don't use flexBasis on web for message post embeds (#4303) * don't use flexBasis on web * rm unnecessary style --- src/components/dms/MessageItemEmbed.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index 5d3656bac1..dbdbe95b56 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {AppBskyEmbedRecord} from '@atproto/api' import {PostEmbeds} from '#/view/com/util/post-embeds' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, native, useTheme} from '#/alf' let MessageItemEmbed = ({ embed, @@ -13,7 +13,7 @@ let MessageItemEmbed = ({ const t = useTheme() return ( - + ) From 16f295ca858bd75fba623ca1fc4f559792fd21f3 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 3 Jun 2024 15:33:35 -0700 Subject: [PATCH 04/24] truncate if extending one line acct switcher (#4310) --- src/view/screens/Settings/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 49702ae47c..a647ea902d 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -103,10 +103,10 @@ function SettingsAccountCard({ /> - + {profile?.displayName || account.handle} - + {account.handle} @@ -381,7 +381,7 @@ export function SettingsScreen({}: Props) { {!currentAccount.emailConfirmed && } - + Signed in as From bda10510a479d0c9ce710b74249b0b7c47adf0c7 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 3 Jun 2024 15:35:57 -0700 Subject: [PATCH 05/24] use the new icon in reposted by (#4307) * use the new icon in reposted by * tweak --- src/view/com/posts/FeedItem.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 72c8b8757a..675f23a88c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -43,6 +43,7 @@ import {Text} from '../util/text/Text' import {PreviewableUserAvatar} from '../util/UserAvatar' import {AviFollowButton} from './AviFollowButton' import hairlineWidth = StyleSheet.hairlineWidth +import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' interface FeedItemProps { record: AppBskyFeedPost.Record @@ -251,13 +252,10 @@ let FeedItemInner = ({ )}`, )} onBeforePress={onOpenReposter}> - Date: Tue, 4 Jun 2024 07:41:03 +0900 Subject: [PATCH 06/24] Fix filtering uris of fetchSubjects (#4324) --- src/state/queries/notifications/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index ebcdff6866..4662493533 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -145,7 +145,7 @@ async function fetchSubjects( ): Promise> { const uris = new Set() for (const notif of groupedNotifs) { - if (notif.subjectUri && !notif.subjectUri.includes('feed.generator')) { + if (notif.subjectUri?.includes('app.bsky.feed.post')) { uris.add(notif.subjectUri) } } From 8d8323421c5f9c9f850f2b4e6fd4c62b932e14b2 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 3 Jun 2024 15:58:16 -0700 Subject: [PATCH 07/24] remove resolution from post thread (#4297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/state/queries/notifications/feed.ts | 18 +++++--- src/state/queries/post-feed.ts | 46 +++++++++++++++------ src/state/queries/post-thread.ts | 25 ++++++----- src/state/queries/search-posts.ts | 14 +++++-- src/state/queries/util.ts | 19 +++++++++ src/view/screens/PostThread.tsx | 55 ++++++++++--------------- 6 files changed, 112 insertions(+), 65 deletions(-) diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 40be2ce8ee..d9f019af38 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -17,7 +17,7 @@ */ import {useEffect, useRef} from 'react' -import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' import { InfiniteData, QueryClient, @@ -30,7 +30,11 @@ import {useMutedThreads} from '#/state/muted-threads' import {useAgent} from '#/state/session' import {useModerationOpts} from '../../preferences/moderation-opts' import {STALE} from '..' -import {embedViewRecordToPostView, getEmbeddedPost} from '../util' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from '../util' import {FeedPage} from './types' import {useUnreadNotificationsApi} from './unread' import {fetchPage} from './util' @@ -142,6 +146,8 @@ export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { + const atUri = new AtUri(uri) + const queryDatas = queryClient.getQueriesData>({ queryKey: [RQKEY_ROOT], }) @@ -149,14 +155,16 @@ export function* findAllPostsInQueryData( if (!queryData?.pages) { continue } + for (const page of queryData?.pages) { for (const item of page.items) { - if (item.subject?.uri === uri) { + if (item.subject && didOrHandleUriMatches(atUri, item.subject)) { yield item.subject } + const quotedPost = getEmbeddedPost(item.subject?.embed) - if (quotedPost?.uri === uri) { - yield embedViewRecordToPostView(quotedPost) + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { + yield embedViewRecordToPostView(quotedPost!) } } } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 5c483483ac..2fb80de37d 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -35,7 +35,11 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' import {useModerationOpts} from '../preferences/moderation-opts' import {usePreferencesQuery} from './preferences' -import {embedViewRecordToPostView, getEmbeddedPost} from './util' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from './util' type ActorDid = string type AuthorFilter = @@ -448,6 +452,8 @@ export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { + const atUri = new AtUri(uri) + const queryDatas = queryClient.getQueriesData< InfiniteData >({ @@ -459,24 +465,38 @@ export function* findAllPostsInQueryData( } for (const page of queryData?.pages) { for (const item of page.feed) { - if (item.post.uri === uri) { + if (didOrHandleUriMatches(atUri, item.post)) { yield item.post } + const quotedPost = getEmbeddedPost(item.post.embed) - if (quotedPost?.uri === uri) { + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { yield embedViewRecordToPostView(quotedPost) } - if ( - AppBskyFeedDefs.isPostView(item.reply?.parent) && - item.reply?.parent?.uri === uri - ) { - yield item.reply.parent + + if (AppBskyFeedDefs.isPostView(item.reply?.parent)) { + if (didOrHandleUriMatches(atUri, item.reply.parent)) { + yield item.reply.parent + } + + const parentQuotedPost = getEmbeddedPost(item.reply.parent.embed) + if ( + parentQuotedPost && + didOrHandleUriMatches(atUri, parentQuotedPost) + ) { + yield embedViewRecordToPostView(parentQuotedPost) + } } - if ( - AppBskyFeedDefs.isPostView(item.reply?.root) && - item.reply?.root?.uri === uri - ) { - yield item.reply.root + + if (AppBskyFeedDefs.isPostView(item.reply?.root)) { + if (didOrHandleUriMatches(atUri, item.reply.root)) { + yield item.reply.root + } + + const rootQuotedPost = getEmbeddedPost(item.reply.root.embed) + if (rootQuotedPost && didOrHandleUriMatches(atUri, rootQuotedPost)) { + yield embedViewRecordToPostView(rootQuotedPost) + } } } } diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index b1bff1493f..f7d21a4270 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -4,6 +4,7 @@ import { AppBskyFeedDefs, AppBskyFeedGetPostThread, AppBskyFeedPost, + AtUri, ModerationDecision, ModerationOpts, } from '@atproto/api' @@ -24,7 +25,11 @@ import { findAllPostsInQueryData as findAllPostsInFeedQueryData, findAllProfilesInQueryData as findAllProfilesInFeedQueryData, } from './post-feed' -import {embedViewRecordToPostView, getEmbeddedPost} from './util' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from './util' const RQKEY_ROOT = 'post-thread' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] @@ -91,14 +96,10 @@ export function usePostThreadQuery(uri: string | undefined) { }, enabled: !!uri, placeholderData: () => { - if (!uri) { - return undefined - } - { - const post = findPostInQueryData(queryClient, uri) - if (post) { - return post - } + if (!uri) return + const post = findPostInQueryData(queryClient, uri) + if (post) { + return post } return undefined }, @@ -271,6 +272,8 @@ export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { + const atUri = new AtUri(uri) + const queryDatas = queryClient.getQueriesData({ queryKey: [RQKEY_ROOT], }) @@ -279,7 +282,7 @@ export function* findAllPostsInQueryData( continue } for (const item of traverseThread(queryData)) { - if (item.uri === uri) { + if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) { const placeholder = threadNodeToPlaceholderThread(item) if (placeholder) { yield placeholder @@ -287,7 +290,7 @@ export function* findAllPostsInQueryData( } const quotedPost = item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined - if (quotedPost?.uri === uri) { + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { yield embedViewRecordToPlaceholderThread(quotedPost) } } diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts index f71d642551..5c50ad2671 100644 --- a/src/state/queries/search-posts.ts +++ b/src/state/queries/search-posts.ts @@ -2,6 +2,7 @@ import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedSearchPosts, + AtUri, } from '@atproto/api' import { InfiniteData, @@ -11,7 +12,11 @@ import { } from '@tanstack/react-query' import {useAgent} from '#/state/session' -import {embedViewRecordToPostView, getEmbeddedPost} from './util' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from './util' const searchPostsQueryKeyRoot = 'search-posts' const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [ @@ -62,17 +67,20 @@ export function* findAllPostsInQueryData( >({ queryKey: [searchPostsQueryKeyRoot], }) + const atUri = new AtUri(uri) + for (const [_queryKey, queryData] of queryDatas) { if (!queryData?.pages) { continue } for (const page of queryData?.pages) { for (const post of page.posts) { - if (post.uri === uri) { + if (didOrHandleUriMatches(atUri, post)) { yield post } + const quotedPost = getEmbeddedPost(post.embed) - if (quotedPost?.uri === uri) { + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { yield embedViewRecordToPostView(quotedPost) } } diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index b74893fcd1..f733c37886 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -1,8 +1,10 @@ import { + AppBskyActorDefs, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, + AtUri, } from '@atproto/api' import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query' @@ -22,6 +24,23 @@ export function truncateAndInvalidate( queryClient.invalidateQueries({queryKey}) } +// Given an AtUri, this function will check if the AtUri matches a +// hit regardless of whether the AtUri uses a DID or handle as a host. +// +// AtUri should be the URI that is being searched for, while currentUri +// is the URI that is being checked. currentAuthor is the author +// of the currentUri that is being checked. +export function didOrHandleUriMatches( + atUri: AtUri, + record: {uri: string; author: AppBskyActorDefs.ProfileViewBasic}, +) { + if (atUri.host.startsWith('did:')) { + return atUri.href === record.uri + } + + return atUri.host === record.author.handle && record.uri.endsWith(atUri.rkey) +} + export function getEmbeddedPost( v: unknown, ): AppBskyEmbedRecord.ViewRecord | undefined { diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index ba1fa130ee..70378f4b81 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,28 +1,26 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import Animated from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' -import {ComposePrompt} from 'view/com/composer/Prompt' -import {s} from 'lib/styles' -import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {clamp} from 'lodash' + +import {isWeb} from '#/platform/detection' import { RQKEY as POST_THREAD_RQKEY, ThreadNode, } from '#/state/queries/post-thread' -import {clamp} from 'lodash' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' -import {useSetMinimalShellMode} from '#/state/shell' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {ErrorMessage} from '../com/util/error/ErrorMessage' -import {CenteredView} from '../com/util/Views' -import {useComposerControls} from '#/state/shell/composer' import {useSession} from '#/state/session' -import {isWeb} from '#/platform/detection' +import {useSetMinimalShellMode} from '#/state/shell' +import {useComposerControls} from '#/state/shell/composer' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {s} from 'lib/styles' +import {ComposePrompt} from 'view/com/composer/Prompt' +import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' type Props = NativeStackScreenProps export function PostThreadScreen({route}: Props) { @@ -35,7 +33,6 @@ export function PostThreadScreen({route}: Props) { const {name, rkey} = route.params const {isMobile} = useWebMediaQueries() const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri) const [canReply, setCanReply] = React.useState(false) useFocusEffect( @@ -45,12 +42,10 @@ export function PostThreadScreen({route}: Props) { ) const onPressReply = React.useCallback(() => { - if (!resolvedUri) { + if (!uri) { return } - const thread = queryClient.getQueryData( - POST_THREAD_RQKEY(resolvedUri.uri), - ) + const thread = queryClient.getQueryData(POST_THREAD_RQKEY(uri)) if (thread?.type !== 'post') { return } @@ -64,25 +59,19 @@ export function PostThreadScreen({route}: Props) { }, onPost: () => queryClient.invalidateQueries({ - queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''), + queryKey: POST_THREAD_RQKEY(uri), }), }) - }, [openComposer, queryClient, resolvedUri]) + }, [openComposer, queryClient, uri]) return ( - {uriError ? ( - - - - ) : ( - - )} + {isMobile && canReply && hasSession && ( Date: Tue, 4 Jun 2024 01:05:26 +0200 Subject: [PATCH 08/24] Unify profile tabs and lists screens placeholders (#4315) --- src/view/com/feeds/ProfileFeedgens.tsx | 18 +++++++----------- src/view/com/lists/MyLists.tsx | 19 +++++++++---------- src/view/com/lists/ProfileLists.tsx | 18 ++++++++---------- src/view/com/modals/UserAddRemoveLists.tsx | 8 ++------ src/view/com/util/EmptyState.tsx | 9 ++++++--- src/view/screens/Lists.tsx | 12 ++++++------ 6 files changed, 38 insertions(+), 46 deletions(-) diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 670cd3e11c..5977e6af99 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -7,7 +7,7 @@ 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' @@ -18,12 +18,11 @@ import {isNative} from '#/platform/detection' import {hydrateFeedGenerator} from '#/state/queries/feed' import {usePreferencesQuery} from '#/state/queries/preferences' import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' -import {usePalette} from 'lib/hooks/usePalette' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {EmptyState} from 'view/com/util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {Text} from '../util/text/Text' import {FeedSourceCardLoaded} from './FeedSourceCard' const LOADING = {_reactKey: '__loading__'} @@ -52,7 +51,6 @@ export const ProfileFeedgens = React.forwardRef< {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, ref, ) { - const pal = usePalette('default') const {_} = useLingui() const theme = useTheme() const [isPTRing, setIsPTRing] = React.useState(false) @@ -138,13 +136,11 @@ export const ProfileFeedgens = React.forwardRef< ({item, index}: ListRenderItemInfo) => { if (item === EMPTY) { return ( - - - You have no feeds. - - + /> ) } else if (item === ERROR_ITEM) { return ( @@ -176,7 +172,7 @@ export const ProfileFeedgens = React.forwardRef< } return null }, - [error, refetch, onPressRetryLoadMore, pal, preferences, _], + [error, refetch, onPressRetryLoadMore, preferences, _], ) React.useEffect(() => { diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx index 5ea95971ca..472d2688c7 100644 --- a/src/view/com/lists/MyLists.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -9,7 +9,8 @@ import { ViewStyle, } from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' @@ -17,11 +18,10 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' +import {EmptyState} from 'view/com/util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' import {List} from '../util/List' -import {Text} from '../util/text/Text' import {ListCard} from './ListCard' -import hairlineWidth = StyleSheet.hairlineWidth const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} @@ -42,6 +42,7 @@ export function MyLists({ }) { const pal = usePalette('default') const {track} = useAnalytics() + const {_} = useLingui() const [isPTRing, setIsPTRing] = React.useState(false) const {data, isFetching, isFetched, isError, error, refetch} = useMyListsQuery(filter) @@ -83,14 +84,12 @@ export function MyLists({ ({item, index}: {item: any; index: number}) => { if (item === EMPTY) { return ( - - - You have no lists. - - + /> ) } else if (item === ERROR_ITEM) { return ( @@ -118,7 +117,7 @@ export function MyLists({ /> ) }, - [error, onRefresh, renderItem, pal], + [error, onRefresh, renderItem, _], ) if (inline) { diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index d1ef05f124..8c3a151fa8 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -7,7 +7,7 @@ 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' @@ -17,12 +17,11 @@ import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists' import {useAnalytics} from 'lib/analytics/analytics' -import {usePalette} from 'lib/hooks/usePalette' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {EmptyState} from 'view/com/util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {Text} from '../util/text/Text' import {ListCard} from './ListCard' const LOADING = {_reactKey: '__loading__'} @@ -49,7 +48,6 @@ export const ProfileLists = React.forwardRef( {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, ref, ) { - const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() const {_} = useLingui() @@ -142,11 +140,11 @@ export const ProfileLists = React.forwardRef( ({item, index}: ListRenderItemInfo) => { if (item === EMPTY) { return ( - - - You have no lists. - - + ) } else if (item === ERROR_ITEM) { return ( @@ -176,7 +174,7 @@ export const ProfileLists = React.forwardRef( /> ) }, - [error, refetch, onPressRetryLoadMore, pal, _], + [error, refetch, onPressRetryLoadMore, _], ) React.useEffect(() => { diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index 8a61b1a707..995af7da2c 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -61,7 +61,7 @@ export function Component({ return [pal.border, {height: screenHeight / 1.5}] } - return [pal.border, {flex: 1}] + return [pal.border, {flex: 1, borderTopWidth: 1}] }, [pal.border, screenHeight]) return ( @@ -233,11 +233,7 @@ const styles = StyleSheet.create({ textAlign: 'center', fontWeight: 'bold', fontSize: 24, - marginBottom: 10, - }, - list: { - flex: 1, - borderTopWidth: 1, + marginBottom: 12, }, btns: { position: 'relative', diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index 7486b212fa..150a16aaa3 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -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 ( - + {icon === 'user-group' ? ( @@ -48,9 +51,9 @@ export function EmptyState({ const styles = StyleSheet.create({ container: { - paddingVertical: 20, + paddingVertical: 24, paddingHorizontal: 36, - borderTopWidth: 1, + borderTopWidth: isWeb ? 1 : undefined, }, iconContainer: { flexDirection: 'row', diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index 0dd2febcb6..12ea6f48be 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -52,12 +52,12 @@ export function ListsScreen({}: Props) { + style={[ + pal.border, + isMobile + ? {borderBottomWidth: hairlineWidth} + : {borderLeftWidth: hairlineWidth, borderRightWidth: hairlineWidth}, + ]}> User Lists From 8c596b61c018e0156a92fe7d0ca7c4b9bcd2d46d Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 3 Jun 2024 16:34:37 -0700 Subject: [PATCH 09/24] 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 --- src/view/com/modals/UserAddRemoveLists.tsx | 55 ++++++++++++---------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index 995af7da2c..88506da570 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -6,28 +6,30 @@ import { View, } from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' -import {MyLists} from '../lists/MyLists' -import {Button} from '../util/forms/Button' -import * as Toast from '../util/Toast' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb, isAndroid, isMobileWeb} from 'platform/detection' -import {Trans, msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' + +import {cleanError} from '#/lib/strings/errors' import {useModalControls} from '#/state/modals' import { - useDangerousListMembershipsQuery, getMembership, ListMembersip, + useDangerousListMembershipsQuery, useListMembershipAddMutation, useListMembershipRemoveMutation, } from '#/state/queries/list-memberships' -import {cleanError} from '#/lib/strings/errors' import {useSession} from '#/state/session' +import {usePalette} from 'lib/hooks/usePalette' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {s} from 'lib/styles' +import {isAndroid, isMobileWeb, isWeb} from 'platform/detection' +import {MyLists} from '../lists/MyLists' +import {Button} from '../util/forms/Button' +import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' +import {UserAvatar} from '../util/UserAvatar' +import hairlineWidth = StyleSheet.hairlineWidth export const snapPoints = ['fullscreen'] @@ -61,12 +63,23 @@ export function Component({ return [pal.border, {height: screenHeight / 1.5}] } - return [pal.border, {flex: 1, borderTopWidth: 1}] + return [pal.border, {flex: 1, borderTopWidth: hairlineWidth}] }, [pal.border, screenHeight]) return ( - + Update {displayName} in Lists @@ -229,12 +240,6 @@ const styles = StyleSheet.create({ container: { paddingHorizontal: isWeb ? 0 : 16, }, - title: { - textAlign: 'center', - fontWeight: 'bold', - fontSize: 24, - marginBottom: 12, - }, btns: { position: 'relative', flexDirection: 'row', @@ -243,7 +248,7 @@ const styles = StyleSheet.create({ gap: 10, paddingTop: 10, paddingBottom: isAndroid ? 10 : 0, - borderTopWidth: 1, + borderTopWidth: hairlineWidth, }, footerBtn: { paddingHorizontal: 24, From 3b55f61d5f0111287be56b76a1a342256d3f2a95 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 4 Jun 2024 00:38:12 +0100 Subject: [PATCH 10/24] Avi follow experiment tweaks (#4341) * Move avi button to visually align content * Fix wrong prop warning * Remove avi follow from post thread --- src/view/com/post-thread/PostThreadItem.tsx | 17 ++++++----------- src/view/com/posts/AviFollowButton.tsx | 4 ++-- src/view/com/posts/AviFollowButton.web.tsx | 6 +++++- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 096305a230..4827aef512 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -40,7 +40,6 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {PostAlerts} from '../../../components/moderation/PostAlerts' import {PostHider} from '../../../components/moderation/PostHider' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' -import {AviFollowButton} from '../posts/AviFollowButton' import {WhoCanReply} from '../threadgate/WhoCanReply' import {ErrorMessage} from '../util/error/ErrorMessage' import {Link, TextLink} from '../util/Link' @@ -472,16 +471,12 @@ let PostThreadItemLoaded = ({ {/* If we are in threaded mode, the avatar is rendered in PostMeta */} {!isThreadedChild && ( - - - + {showChildReplyLine && ( Date: Tue, 4 Jun 2024 02:49:50 +0300 Subject: [PATCH 11/24] 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 --- .../icons/image_stroke2_corner0_rounded.svg | 2 +- src/components/icons/Image.tsx | 2 +- src/view/com/composer/Composer.tsx | 145 +++++++++++++++--- .../com/composer/threadgate/ThreadgateBtn.tsx | 9 +- 4 files changed, 130 insertions(+), 28 deletions(-) diff --git a/assets/icons/image_stroke2_corner0_rounded.svg b/assets/icons/image_stroke2_corner0_rounded.svg index 389020b0d1..3363e186db 100644 --- a/assets/icons/image_stroke2_corner0_rounded.svg +++ b/assets/icons/image_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/components/icons/Image.tsx b/src/components/icons/Image.tsx index 03702a0f46..eac296ad42 100644 --- a/src/components/icons/Image.tsx +++ b/src/components/icons/Image.tsx @@ -1,5 +1,5 @@ import {createSinglePathSVG} from './TEMPLATE' export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({ - path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z', + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z', }) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b1c020a105..ad79cdb58c 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -9,6 +9,7 @@ import React, { import { ActivityIndicator, Keyboard, + LayoutChangeEvent, StyleSheet, TouchableOpacity, View, @@ -19,6 +20,7 @@ import { } from 'react-native-keyboard-controller' import Animated, { interpolateColor, + runOnUI, useAnimatedStyle, useSharedValue, withTiming, @@ -170,22 +172,6 @@ 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) { closeAllDialogs() @@ -395,13 +381,21 @@ export const ComposePost = observer(function ComposePost({ [setExtLink], ) + const { + scrollHandler, + onScrollViewContentSizeChange, + onScrollViewLayout, + topBarAnimatedStyle, + bottomBarAnimatedStyle, + } = useAnimatedBorders() + return ( <> + keyboardVerticalOffset={replyTo ? 110 : isAndroid ? 180 : 140}> + keyboardShouldPersistTaps="always" + onContentSizeChange={onScrollViewContentSizeChange} + onLayout={onScrollViewLayout}> {replyTo ? : undefined} {replyTo ? null : ( - + )} (null) } +function useAnimatedBorders() { + const t = useTheme() + const hasScrolledTop = useSharedValue(0) + const hasScrolledBottom = useSharedValue(0) + const contentOffset = useSharedValue(0) + const scrollViewHeight = useSharedValue(Infinity) + const contentHeight = useSharedValue(0) + + /** + * Make sure to run this on the UI thread! + */ + const showHideBottomBorder = useCallback( + ({ + newContentHeight, + newContentOffset, + newScrollViewHeight, + }: { + newContentHeight?: number + newContentOffset?: number + newScrollViewHeight?: number + }) => { + 'worklet' + + if (typeof newContentHeight === 'number') + contentHeight.value = newContentHeight + if (typeof newContentOffset === 'number') + contentOffset.value = newContentOffset + if (typeof newScrollViewHeight === 'number') + scrollViewHeight.value = newScrollViewHeight + + hasScrolledBottom.value = withTiming( + contentHeight.value - contentOffset.value >= scrollViewHeight.value + ? 1 + : 0, + ) + }, + [contentHeight, contentOffset, scrollViewHeight, hasScrolledBottom], + ) + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: event => { + hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0) + + // already on UI thread + showHideBottomBorder({ + newContentOffset: event.contentOffset.y, + newContentHeight: event.contentSize.height, + newScrollViewHeight: event.layoutMeasurement.height, + }) + }, + }) + + const onScrollViewContentSizeChange = useCallback( + (_width: number, height: number) => { + runOnUI(showHideBottomBorder)({ + newContentHeight: height, + }) + }, + [showHideBottomBorder], + ) + + const onScrollViewLayout = useCallback( + (evt: LayoutChangeEvent) => { + runOnUI(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, - }, + topbar: {}, topbarDesktop: { paddingTop: 10, paddingBottom: 10, @@ -698,7 +796,8 @@ const styles = StyleSheet.create({ bottomBar: { flexDirection: 'row', paddingVertical: 4, - paddingLeft: 8, + // should be 8 but due to visual alignment we have to fudge it + paddingLeft: 7, paddingRight: 16, alignItems: 'center', borderTopWidth: hairlineWidth, diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index afc9f5bfad..2aefdfbbf3 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {Keyboard, View} from 'react-native' +import {Keyboard, StyleProp, ViewStyle} from 'react-native' +import Animated, {AnimatedStyle} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -16,9 +17,11 @@ import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' export function ThreadgateBtn({ threadgate, onChange, + style, }: { threadgate: ThreadgateSetting[] onChange: (v: ThreadgateSetting[]) => void + style?: StyleProp> }) { const {track} = useAnalytics() const {_} = useLingui() @@ -46,7 +49,7 @@ export function ThreadgateBtn({ : _(msg`Some people can reply`) return ( - + - + ) } From bd4703ca1e5e4620f8c700e70477d2e0e6b04d67 Mon Sep 17 00:00:00 2001 From: Thomas Dickerson Date: Mon, 3 Jun 2024 20:29:45 -0400 Subject: [PATCH 12/24] 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 --- __tests__/lib/string.test.ts | 80 +++++++++++++++++++++++++++++++++ src/lib/strings/embed-player.ts | 76 +++++++++++++++++++++++++++++++ src/state/persisted/schema.ts | 1 + 3 files changed, 157 insertions(+) diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index cf21d8dd25..78478a26d4 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -480,6 +480,26 @@ describe('parseEmbedPlayerFromUrl', () => { 'https://media.tenor.com/someID/someName.gif', 'https://media.tenor.com/someID', 'https://media.tenor.com', + + 'https://www.flickr.com/photos/username/albums/72177720308493661', + 'https://flickr.com/photos/username/albums/72177720308493661', + 'https://flickr.com/photos/username/albums/72177720308493661/', + 'https://flickr.com/photos/username/albums/72177720308493661//', + 'https://flic.kr/s/aHBqjAES3i', + + 'https://flickr.com/foetoes/username/albums/3903', + 'https://flickr.com/albums/3903', + 'https://flic.kr/s/OolI', + 'https://flic.kr/t/aHBqjAES3i', + + 'https://www.flickr.com/groups/898944@N23/pool', + 'https://flickr.com/groups/898944@N23/pool', + 'https://flickr.com/groups/898944@N23/pool/', + 'https://flickr.com/groups/898944@N23/pool//', + 'https://flic.kr/go/8WJtR', + + 'https://www.flickr.com/groups/898944@N23/', + 'https://www.flickr.com/groups', ] const outputs = [ @@ -777,6 +797,66 @@ describe('parseEmbedPlayerFromUrl', () => { undefined, undefined, undefined, + + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + + undefined, + undefined, + undefined, + undefined, + + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + + undefined, + undefined, ] it('correctly grabs the correct id from uri', () => { diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index 54649f1431..30ced14921 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -23,6 +23,7 @@ export const embedPlayerSources = [ 'vimeo', 'giphy', 'tenor', + 'flickr', ] as const export type EmbedPlayerSource = (typeof embedPlayerSources)[number] @@ -42,6 +43,7 @@ export type EmbedPlayerType = | 'vimeo_video' | 'giphy_gif' | 'tenor_gif' + | 'flickr_album' export const externalEmbedLabels: Record = { youtube: 'YouTube', @@ -53,6 +55,7 @@ export const externalEmbedLabels: Record = { spotify: 'Spotify', appleMusic: 'Apple Music', soundcloud: 'SoundCloud', + flickr: 'Flickr', } export interface EmbedPlayerParams { @@ -375,6 +378,79 @@ export function parseEmbedPlayerFromUrl( } } } + + // this is a standard flickr path! we can use the embedder for albums and groups, so validate the path + if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') { + let i = urlp.pathname.length - 1 + while (i > 0 && urlp.pathname.charAt(i) === '/') { + --i + } + + const path_components = urlp.pathname.slice(1, i + 1).split('/') + if (path_components.length === 4) { + // discard username - it's not relevant + const [photos, _, albums, id] = path_components + if (photos === 'photos' && albums === 'albums') { + // this at least has the shape of a valid photo-album URL! + return { + type: 'flickr_album', + source: 'flickr', + playerUri: `https://embedr.flickr.com/photosets/${id}`, + } + } + } + + if (path_components.length === 3) { + const [groups, id, pool] = path_components + if (groups === 'groups' && pool === 'pool') { + return { + type: 'flickr_album', + source: 'flickr', + playerUri: `https://embedr.flickr.com/groups/${id}`, + } + } + } + // not an album or a group pool, don't know what to do with this! + return undefined + } + + // link shortened flickr path + if (urlp.hostname === 'flic.kr') { + const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ' + let [_, type, idBase58Enc] = urlp.pathname.split('/') + let id = 0n + for (const char of idBase58Enc) { + const nextIdx = b58alph.indexOf(char) + if (nextIdx >= 0) { + id = id * 58n + BigInt(nextIdx) + } else { + // not b58 encoded, ergo not a valid link to embed + return undefined + } + } + + switch (type) { + case 'go': + const formattedGroupId = `${id}` + return { + type: 'flickr_album', + source: 'flickr', + playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice( + 0, + -2, + )}@N${formattedGroupId.slice(-2)}`, + } + case 's': + return { + type: 'flickr_album', + source: 'flickr', + playerUri: `https://embedr.flickr.com/photosets/${id}`, + } + default: + // we don't know what this is so we can't embed it + return undefined + } + } } export function getPlayerAspect({ diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 77a79b78e4..1860d34de2 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -65,6 +65,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 From b02445883ab5abd7daa80c3a27cf06ffaf539ff3 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 3 Jun 2024 17:32:58 -0700 Subject: [PATCH 13/24] 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 f89bfeefb7e007b802cb47a8eca8fe6206bbf60f. --- .github/workflows/build-submit-android.yml | 26 ++++++++++++++++++++++ eas.json | 14 ++++++++++++ 2 files changed, 40 insertions(+) diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index c487c2ab8a..ec9e0d320e 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -120,6 +120,32 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + - name: 🏗️ Build Production APK + if: ${{ inputs.profile == 'production' }} + run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive + + - name: 🚀 Upload Production APK Artifact + id: upload-artifact-production-apk + if: ${{ inputs.profile == 'production' }} + uses: actions/upload-artifact@v4 + with: + retention-days: 30 + compression-level: 6 + name: build-${{ steps.timestamp.outputs.time }}.apk + path: build.apk + + - name: 🔔 Notify Slack of Production APK Build + if: ${{ inputs.profile == 'production' }} + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "text": "Android production APK build is ready for download. This is a production build, and you should add it to the GitHub release! Download the artifact here: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + - name: ⬇️ Restore Cache id: get-base-commit uses: actions/cache@v4 diff --git a/eas.json b/eas.json index ed647dbb9c..a705c40027 100644 --- a/eas.json +++ b/eas.json @@ -46,6 +46,20 @@ "EXPO_PUBLIC_ENV": "production" } }, + "production-apk": { + "extends": "base", + "distribution": "internal", + "ios": { + "autoIncrement": false + }, + "android": { + "autoIncrement": false + }, + "channel": "production", + "env": { + "EXPO_PUBLIC_ENV": "production" + } + }, "testflight": { "extends": "base", "ios": { From da96fb1ef5a37018b6a238c3614e9b845d8e2686 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 4 Jun 2024 04:05:46 +0300 Subject: [PATCH 14/24] 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 --- src/components/dialogs/GifSelect.ios.tsx | 255 ++++++++++++++++++ src/components/dialogs/GifSelect.shared.tsx | 53 ++++ src/components/dialogs/GifSelect.tsx | 65 ++--- src/view/com/composer/Composer.tsx | 5 +- src/view/com/composer/photos/SelectGifBtn.tsx | 11 +- 5 files changed, 331 insertions(+), 58 deletions(-) create mode 100644 src/components/dialogs/GifSelect.ios.tsx create mode 100644 src/components/dialogs/GifSelect.shared.tsx diff --git a/src/components/dialogs/GifSelect.ios.tsx b/src/components/dialogs/GifSelect.ios.tsx new file mode 100644 index 0000000000..091a23e51c --- /dev/null +++ b/src/components/dialogs/GifSelect.ios.tsx @@ -0,0 +1,255 @@ +import React, { + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import {Modal, ScrollView, TextInput, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {cleanError} from '#/lib/strings/errors' +import { + Gif, + useFeaturedGifsQuery, + useGifSearchQuery, +} from '#/state/queries/tenor' +import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {FlatList_INTERNAL} from '#/view/com/util/Views' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {Button, ButtonText} from '../Button' +import {Handle} from '../Dialog' +import {useThrottledValue} from '../hooks/useThrottledValue' +import {ListFooter, ListMaybePlaceholder} from '../Lists' +import {GifPreview} from './GifSelect.shared' + +export function GifSelectDialog({ + controlRef, + onClose, + onSelectGif: onSelectGifProp, +}: { + controlRef: React.RefObject<{open: () => void}> + onClose: () => void + onSelectGif: (gif: Gif) => void +}) { + const t = useTheme() + const [open, setOpen] = useState(false) + + useImperativeHandle(controlRef, () => ({ + open: () => setOpen(true), + })) + + const close = useCallback(() => { + setOpen(false) + onClose() + }, [onClose]) + + const onSelectGif = useCallback( + (gif: Gif) => { + onSelectGifProp(gif) + close() + }, + [onSelectGifProp, close], + ) + + const renderErrorBoundary = useCallback( + (error: any) => , + [close], + ) + + return ( + + + + + + + + + ) +} + +function GifList({ + onSelectGif, +}: { + close: () => void + onSelectGif: (gif: Gif) => void +}) { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const textInputRef = useRef(null) + const listRef = useRef(null) + const [undeferredSearch, setSearch] = useState('') + const search = useThrottledValue(undeferredSearch, 500) + + const isSearching = search.length > 0 + + const trendingQuery = useFeaturedGifsQuery() + const searchQuery = useGifSearchQuery(search) + + const { + data, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + error, + isLoading, + isError, + refetch, + } = isSearching ? searchQuery : trendingQuery + + const flattenedData = useMemo(() => { + return data?.pages.flatMap(page => page.results) || [] + }, [data]) + + const renderItem = useCallback( + ({item}: {item: Gif}) => { + return + }, + [onSelectGif], + ) + + const onEndReached = React.useCallback(() => { + if (isFetchingNextPage || !hasNextPage || error) return + fetchNextPage() + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + + const hasData = flattenedData.length > 0 + + const onGoBack = useCallback(() => { + if (isSearching) { + // clear the input and reset the state + textInputRef.current?.clear() + setSearch('') + } else { + close() + } + }, [isSearching]) + + const listHeader = useMemo(() => { + return ( + + {/* cover top corners */} + + + + + { + setSearch(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + returnKeyType="search" + clearButtonMode="while-editing" + inputRef={textInputRef} + maxLength={50} + /> + + + ) + }, [t.atoms.bg, _]) + + return ( + + {listHeader} + {!hasData && ( + + )} + + } + stickyHeaderIndices={[0]} + onEndReached={onEndReached} + onEndReachedThreshold={4} + keyExtractor={(item: Gif) => item.id} + keyboardDismissMode="on-drag" + ListFooterComponent={ + hasData ? ( + + ) : null + } + /> + ) +} + +function ModalError({details, close}: {details?: string; close: () => void}) { + const {_} = useLingui() + + return ( + + + + + ) +} diff --git a/src/components/dialogs/GifSelect.shared.tsx b/src/components/dialogs/GifSelect.shared.tsx new file mode 100644 index 0000000000..90b2abaa83 --- /dev/null +++ b/src/components/dialogs/GifSelect.shared.tsx @@ -0,0 +1,53 @@ +import React, {useCallback} from 'react' +import {Image} from 'expo-image' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logEvent} from '#/lib/statsig/statsig' +import {Gif} from '#/state/queries/tenor' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button} from '../Button' + +export function GifPreview({ + gif, + onSelectGif, +}: { + gif: Gif + onSelectGif: (gif: Gif) => void +}) { + const {gtTablet} = useBreakpoints() + const {_} = useLingui() + const t = useTheme() + + const onPress = useCallback(() => { + logEvent('composer:gif:select', {}) + onSelectGif(gif) + }, [onSelectGif, gif]) + + return ( + + ) +} diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index 4a3ce42aa9..a64edcd6f0 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -1,11 +1,15 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react' +import React, { + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import {TextInput, View} from 'react-native' -import {Image} from 'expo-image' import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {isWeb} from '#/platform/detection' import { @@ -23,16 +27,23 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {Button, ButtonIcon, ButtonText} from '../Button' import {ListFooter, ListMaybePlaceholder} from '../Lists' +import {GifPreview} from './GifSelect.shared' export function GifSelectDialog({ - control, + controlRef, onClose, onSelectGif: onSelectGifProp, }: { - control: Dialog.DialogControlProps + controlRef: React.RefObject<{open: () => void}> onClose: () => void onSelectGif: (gif: Gif) => void }) { + const control = Dialog.useDialogControl() + + useImperativeHandle(controlRef, () => ({ + open: () => control.open(), + })) + const onSelectGif = useCallback( (gif: Gif) => { control.close(() => onSelectGifProp(gif)) @@ -233,50 +244,6 @@ function GifList({ ) } -function GifPreview({ - gif, - onSelectGif, -}: { - gif: Gif - onSelectGif: (gif: Gif) => void -}) { - const {gtTablet} = useBreakpoints() - const {_} = useLingui() - const t = useTheme() - - const onPress = useCallback(() => { - logEvent('composer:gif:select', {}) - onSelectGif(gif) - }, [onSelectGif, gif]) - - return ( - - ) -} - function DialogError({details}: {details?: string}) { const {_} = useLingui() const control = Dialog.useDialogContext() diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index ad79cdb58c..93cc87fc82 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -173,7 +173,7 @@ export const ComposePost = observer(function ComposePost({ ) const onPressCancel = useCallback(() => { - if (graphemeLength > 0 || !gallery.isEmpty) { + if (graphemeLength > 0 || !gallery.isEmpty || extGif) { closeAllDialogs() if (Keyboard) { Keyboard.dismiss() @@ -183,6 +183,7 @@ export const ComposePost = observer(function ComposePost({ onClose() } }, [ + extGif, graphemeLength, gallery.isEmpty, closeAllDialogs, @@ -728,8 +729,6 @@ function useAnimatedBorders() { const styles = StyleSheet.create({ topbar: {}, topbarDesktop: { - paddingTop: 10, - paddingBottom: 10, height: 50, }, topbarInner: { diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx index 60cef9a192..d13df0a110 100644 --- a/src/view/com/composer/photos/SelectGifBtn.tsx +++ b/src/view/com/composer/photos/SelectGifBtn.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react' +import React, {useCallback, useRef} from 'react' import {Keyboard} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -7,7 +7,6 @@ import {logEvent} from '#/lib/statsig/statsig' import {Gif} from '#/state/queries/tenor' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' import {GifSelectDialog} from '#/components/dialogs/GifSelect' import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' @@ -19,14 +18,14 @@ type Props = { export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { const {_} = useLingui() - const control = useDialogControl() + const ref = useRef<{open: () => void}>(null) const t = useTheme() const onPressSelectGif = useCallback(async () => { logEvent('composer:gif:open', {}) Keyboard.dismiss() - control.open() - }, [control]) + ref.current?.open() + }, []) return ( <> @@ -44,7 +43,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { From de93e8de746f3c8a7b1755aaa034043951371ae0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 3 Jun 2024 20:07:01 -0500 Subject: [PATCH 15/24] =?UTF-8?q?[=F0=9F=90=B4]=20Post=20embeds=20polish?= =?UTF-8?q?=20(#4339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Handle message cleanup * Handle last message in chat list * Memoize lastMessage --- src/lib/strings/url-helpers.ts | 21 +++++ .../Messages/Conversation/MessagesList.tsx | 26 +++---- src/screens/Messages/List/ChatListItem.tsx | 77 +++++++++++++++---- 3 files changed, 94 insertions(+), 30 deletions(-) diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 2a20373a42..4c75f47add 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -3,6 +3,7 @@ import psl from 'psl' import TLDs from 'tlds' import {BSKY_SERVICE} from 'lib/constants' +import {isInvalidHandle} from 'lib/strings/handles' export const BSKY_APP_HOST = 'https://bsky.app' const BSKY_TRUSTED_HOSTS = [ @@ -83,6 +84,10 @@ export function toShareUrl(url: string): string { return url } +export function toBskyAppUrl(url: string): string { + return new URL(url, BSKY_APP_HOST).toString() +} + export function isBskyAppUrl(url: string): boolean { return url.startsWith('https://bsky.app/') } @@ -183,6 +188,22 @@ export function feedUriToHref(url: string): string { } } +export function postUriToRelativePath( + uri: string, + options?: {handle?: string}, +): string | undefined { + try { + const {hostname, rkey} = new AtUri(uri) + const handleOrDid = + options?.handle && !isInvalidHandle(options.handle) + ? options.handle + : hostname + return `/profile/${handleOrDid}/post/${rkey}` + } catch { + return undefined + } +} + /** * Checks if the label in the post text matches the host of the link facet. * diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index e6f657b497..f72515ac62 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -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) { diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx index d5658249d7..9f8808366f 100644 --- a/src/screens/Messages/List/ChatListItem.tsx +++ b/src/screens/Messages/List/ChatListItem.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useState} from 'react' import {GestureResponderEvent, View} from 'react-native' import { AppBskyActorDefs, + AppBskyEmbedRecord, ChatBskyConvoDefs, moderateProfile, ModerationOpts, @@ -9,6 +10,11 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import { + postUriToRelativePath, + toBskyAppUrl, + toShortUrl, +} from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -95,21 +101,64 @@ function ChatListItemReady({ const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount - let lastMessage = _(msg`No messages yet`) - let lastMessageSentAt: string | null = null - if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { - if (convo.lastMessage.sender?.did === currentAccount?.did) { - lastMessage = _(msg`You: ${convo.lastMessage.text}`) - } else { - lastMessage = convo.lastMessage.text + const {lastMessage, lastMessageSentAt} = React.useMemo(() => { + let lastMessage = _(msg`No messages yet`) + let lastMessageSentAt: string | null = null + + if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { + const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did + + if (convo.lastMessage.text) { + if (isFromMe) { + lastMessage = _(msg`You: ${convo.lastMessage.text}`) + } else { + lastMessage = convo.lastMessage.text + } + } else if (convo.lastMessage.embed) { + const defaultEmbeddedContentMessage = _( + msg`(contains embedded content)`, + ) + + if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { + const embed = convo.lastMessage.embed + + if (AppBskyEmbedRecord.isViewRecord(embed.record)) { + const record = embed.record + const path = postUriToRelativePath(record.uri, { + handle: record.author.handle, + }) + const href = path ? toBskyAppUrl(path) : undefined + const short = href + ? toShortUrl(href) + : defaultEmbeddedContentMessage + if (isFromMe) { + lastMessage = _(msg`You: ${short}`) + } else { + lastMessage = short + } + } + } else { + if (isFromMe) { + lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) + } else { + lastMessage = defaultEmbeddedContentMessage + } + } + } + + lastMessageSentAt = convo.lastMessage.sentAt } - lastMessageSentAt = convo.lastMessage.sentAt - } - if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { - lastMessage = isDeletedAccount - ? _(msg`Conversation deleted`) - : _(msg`Message deleted`) - } + if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { + lastMessage = isDeletedAccount + ? _(msg`Conversation deleted`) + : _(msg`Message deleted`) + } + + return { + lastMessage, + lastMessageSentAt, + } + }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount]) const [showActions, setShowActions] = useState(false) From 3e1f0768916774642516d88254a6cf7a6a82331f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 3 Jun 2024 20:10:43 -0500 Subject: [PATCH 16/24] =?UTF-8?q?[=F0=9F=99=85]=20Disambiguation=20of=20th?= =?UTF-8?q?e=20deactivation=20(#4267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/lib/statsig/events.ts | 2 +- src/screens/Deactivated.tsx | 321 ++++++++---------- .../components/DeactivateAccountDialog.tsx | 60 ++++ src/screens/SignupQueued.tsx | 219 ++++++++++++ src/state/persisted/schema.ts | 5 +- src/state/session/__tests__/session-test.ts | 87 +++-- src/state/session/agent.ts | 10 +- src/state/session/index.tsx | 2 +- src/state/session/util.ts | 5 +- src/view/com/modals/DeleteAccount.tsx | 54 ++- src/view/screens/Settings/index.tsx | 29 ++ .../createNativeStackNavigatorWithAuth.tsx | 8 +- 12 files changed, 578 insertions(+), 224 deletions(-) create mode 100644 src/screens/Settings/components/DeactivateAccountDialog.tsx create mode 100644 src/screens/SignupQueued.tsx diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 48651b3d96..753734edd8 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -13,7 +13,7 @@ export type LogEvents = { withPassword: boolean } 'account:loggedOut': { - logContext: 'SwitchAccount' | 'Settings' | 'Deactivated' + logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated' } 'notifications:openApp': {} 'notifications:request': { diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index c9e9f95254..faee517cb8 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -1,19 +1,22 @@ import React from 'react' import {View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {msg, plural, Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' -import {logger} from '#/logger' +import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {isWeb} from '#/platform/detection' -import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session' -import {useOnboardingDispatch} from '#/state/shell' +import {type SessionAccount, useSession, useSessionApi} from '#/state/session' +import {useSetMinimalShellMode} from '#/state/shell' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {ScrollView} from '#/view/com/util/Views' import {Logo} from '#/view/icons/Logo' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {Loader} from '#/components/Loader' -import {P, Text} from '#/components/Typography' +import {atoms as a, useTheme} from '#/alf' +import {AccountList} from '#/components/AccountList' +import {Button, ButtonText} from '#/components/Button' +import {Divider} from '#/components/Divider' +import {Text} from '#/components/Typography' const COL_WIDTH = 400 @@ -21,199 +24,151 @@ export function Deactivated() { const {_} = useLingui() const t = useTheme() const insets = useSafeAreaInsets() - const {gtMobile} = useBreakpoints() - const onboardingDispatch = useOnboardingDispatch() + const {currentAccount, accounts} = useSession() + const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() + const {setShowLoggedOut} = useLoggedOutViewControls() + const hasOtherAccounts = accounts.length > 1 + const setMinimalShellMode = useSetMinimalShellMode() const {logout} = useSessionApi() - const agent = useAgent() - const [isProcessing, setProcessing] = React.useState(false) - const [estimatedTime, setEstimatedTime] = React.useState( - undefined, - ) - const [placeInQueue, setPlaceInQueue] = React.useState( - undefined, + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(true) + }, [setMinimalShellMode]), ) - const checkStatus = React.useCallback(async () => { - setProcessing(true) - try { - const res = await agent.com.atproto.temp.checkSignupQueue() - if (res.data.activated) { - // ready to go, exchange the access token for a usable one and kick off onboarding - await agent.refreshSession() - if (!isSessionDeactivated(agent.session?.accessJwt)) { - onboardingDispatch({type: 'start'}) - } - } else { - // not ready, update UI - setEstimatedTime(msToString(res.data.estimatedTimeMs)) - if (typeof res.data.placeInQueue !== 'undefined') { - setPlaceInQueue(Math.max(res.data.placeInQueue, 1)) - } + const onSelectAccount = React.useCallback( + (account: SessionAccount) => { + if (account.did !== currentAccount?.did) { + onPressSwitchAccount(account, 'SwitchAccount') } - } catch (e: any) { - logger.error('Failed to check signup queue', {err: e.toString()}) - } finally { - setProcessing(false) - } - }, [ - setProcessing, - setEstimatedTime, - setPlaceInQueue, - onboardingDispatch, - agent, - ]) - - React.useEffect(() => { - checkStatus() - const interval = setInterval(checkStatus, 60e3) - return () => clearInterval(interval) - }, [checkStatus]) - - const checkBtn = ( - + }, + [currentAccount, onPressSwitchAccount], ) + const onPressAddAccount = React.useCallback(() => { + setShowLoggedOut(true) + }, [setShowLoggedOut]) + + const onPressLogout = React.useCallback(() => { + if (isWeb) { + // We're switching accounts, which remounts the entire app. + // On mobile, this gets us Home, but on the web we also need reset the URL. + // We can't change the URL via a navigate() call because the navigator + // itself is about to unmount, and it calls pushState() too late. + // So we change the URL ourselves. The navigator will pick it up on remount. + history.pushState(null, '', '/') + } + logout('Deactivated') + }, [logout]) + return ( - + - - - - - - - - You're in line - -

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

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

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

-
- - {isWeb && gtMobile && ( - - - {checkBtn} - - )} -
- - - -
- - {(!isWeb || !gtMobile) && ( - - {checkBtn} - + + + + + + + + + Welcome back! + + + + You previously deactivated @{currentAccount?.handle}. + + + + + You can reactivate your account to continue logging in. Your + profile and posts will be visible to other users. + + + + + + + + + + + + + + {hasOtherAccounts ? ( + <> + + Or, log into one of your other accounts. + + + + ) : ( + <> + + Or, continue with another account. + + + + )} + - )} + ) } - -function msToString(ms: number | undefined): string | undefined { - if (ms && ms > 0) { - const estimatedTimeMins = Math.ceil(ms / 60e3) - if (estimatedTimeMins > 59) { - const estimatedTimeHrs = Math.round(estimatedTimeMins / 60) - if (estimatedTimeHrs > 6) { - // dont even bother - return undefined - } - // hours - return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, { - one: 'hour', - other: 'hours', - })}` - } - // minutes - return `${estimatedTimeMins} ${plural(estimatedTimeMins, { - one: 'minute', - other: 'minutes', - })}` - } - return undefined -} diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx new file mode 100644 index 0000000000..4330ffcaa2 --- /dev/null +++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {DialogOuterProps} from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' + +export function DeactivateAccountDialog({ + control, +}: { + control: DialogOuterProps['control'] +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + + {_(msg`Deactivate account`)} + + + Your profile, posts, feeds, and lists will no longer be visible to + other Bluesky users. You can reactivate your account at any time by + logging in. + + + + + + + + + There is no time limit for account deactivation, come back any + time. + + + + + If you're trying to change your handle or email, do so before you + deactivate. + + + + + + + + {}} + color="negative" + /> + + + + ) +} diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx new file mode 100644 index 0000000000..4e4fedcfae --- /dev/null +++ b/src/screens/SignupQueued.tsx @@ -0,0 +1,219 @@ +import React from 'react' +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {isSignupQueued, useAgent, useSessionApi} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' +import {ScrollView} from '#/view/com/util/Views' +import {Logo} from '#/view/icons/Logo' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Loader} from '#/components/Loader' +import {P, Text} from '#/components/Typography' + +const COL_WIDTH = 400 + +export function SignupQueued() { + const {_} = useLingui() + const t = useTheme() + const insets = useSafeAreaInsets() + const {gtMobile} = useBreakpoints() + const onboardingDispatch = useOnboardingDispatch() + const {logout} = useSessionApi() + const agent = useAgent() + + const [isProcessing, setProcessing] = React.useState(false) + const [estimatedTime, setEstimatedTime] = React.useState( + undefined, + ) + const [placeInQueue, setPlaceInQueue] = React.useState( + undefined, + ) + + const checkStatus = React.useCallback(async () => { + setProcessing(true) + try { + const res = await agent.com.atproto.temp.checkSignupQueue() + if (res.data.activated) { + // ready to go, exchange the access token for a usable one and kick off onboarding + await agent.refreshSession() + if (!isSignupQueued(agent.session?.accessJwt)) { + onboardingDispatch({type: 'start'}) + } + } else { + // not ready, update UI + setEstimatedTime(msToString(res.data.estimatedTimeMs)) + if (typeof res.data.placeInQueue !== 'undefined') { + setPlaceInQueue(Math.max(res.data.placeInQueue, 1)) + } + } + } catch (e: any) { + logger.error('Failed to check signup queue', {err: e.toString()}) + } finally { + setProcessing(false) + } + }, [ + setProcessing, + setEstimatedTime, + setPlaceInQueue, + onboardingDispatch, + agent, + ]) + + React.useEffect(() => { + checkStatus() + const interval = setInterval(checkStatus, 60e3) + return () => clearInterval(interval) + }, [checkStatus]) + + const checkBtn = ( + + ) + + return ( + + + + + + + + + + You're in line + +

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

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

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

+
+ + {isWeb && gtMobile && ( + + + {checkBtn} + + )} +
+ + + +
+ + {(!isWeb || !gtMobile) && ( + + + {checkBtn} + + + + )} +
+ ) +} + +function msToString(ms: number | undefined): string | undefined { + if (ms && ms > 0) { + const estimatedTimeMins = Math.ceil(ms / 60e3) + if (estimatedTimeMins > 59) { + const estimatedTimeHrs = Math.round(estimatedTimeMins / 60) + if (estimatedTimeHrs > 6) { + // dont even bother + return undefined + } + // hours + return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, { + one: 'hour', + other: 'hours', + })}` + } + // minutes + return `${estimatedTimeMins} ${plural(estimatedTimeMins, { + one: 'minute', + other: 'minutes', + })}` + } + return undefined +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 1860d34de2..7d579d55de 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -17,7 +17,10 @@ const accountSchema = z.object({ emailAuthFactor: z.boolean().optional(), refreshJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire - deactivated: z.boolean().optional(), + signupQueued: z.boolean().optional(), + status: z + .enum(['active', 'takendown', 'suspended', 'deactivated']) + .optional(), pdsUrl: z.string().optional(), }) export type PersistedAccount = z.infer diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index daf8d70c2c..c8c1e103fb 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -50,7 +50,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -59,6 +58,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -87,7 +88,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -96,6 +96,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -136,7 +138,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -145,6 +146,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -183,7 +186,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -192,10 +194,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "alice-access-jwt-1", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -204,6 +207,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -242,7 +247,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -251,10 +255,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -263,6 +268,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -299,7 +306,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "jay-access-jwt-1", - "deactivated": false, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -308,10 +314,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "jay-refresh-jwt-1", "service": "https://jay.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -320,10 +327,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -332,6 +340,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -364,7 +374,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -373,10 +382,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://jay.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -385,10 +395,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": undefined, - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -397,6 +408,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -446,7 +459,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -455,6 +467,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -490,7 +504,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -499,6 +512,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -601,7 +616,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -610,6 +624,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -681,7 +697,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -690,6 +705,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -731,7 +748,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-3", - "deactivated": false, "did": "alice-did", "email": "alice@foo.baz", "emailAuthFactor": true, @@ -740,6 +756,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-3", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -781,7 +799,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-4", - "deactivated": false, "did": "alice-did", "email": "alice@foo.baz", "emailAuthFactor": false, @@ -790,6 +807,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-4", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -937,7 +956,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -946,10 +964,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -958,6 +977,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -997,7 +1018,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-2", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -1006,10 +1026,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-2", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -1018,6 +1039,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1156,7 +1179,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1165,6 +1187,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1218,7 +1242,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1227,6 +1250,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1280,7 +1305,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1289,6 +1313,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1371,7 +1397,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "jay-access-jwt-1", - "deactivated": false, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -1380,10 +1405,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "jay-refresh-jwt-1", "service": "https://jay.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "bob-access-jwt-2", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -1392,6 +1418,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1429,7 +1457,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "clarence-access-jwt-2", - "deactivated": false, "did": "clarence-did", "email": undefined, "emailAuthFactor": false, @@ -1438,6 +1465,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "clarence-refresh-jwt-2", "service": "https://clarence.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 45013debc2..cdd24cd15a 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -16,7 +16,7 @@ import { configureModerationForGuest, } from './moderation' import {SessionAccount} from './types' -import {isSessionDeactivated, isSessionExpired} from './util' +import {isSessionExpired, isSignupQueued} from './util' export function createPublicAgent() { configureModerationForGuest() // Side effect but only relevant for tests @@ -51,7 +51,7 @@ export async function createAgentAndResume( await networkRetry(1, () => agent.resumeSession(prevSession)) } else { agent.session = prevSession - if (!storedAccount.deactivated) { + if (!storedAccount.signupQueued) { // Intentionally not awaited to unblock the UI: networkRetry(3, () => agent.resumeSession(prevSession)).catch( (e: any) => { @@ -135,7 +135,7 @@ export async function createAgentAndCreateAccount( const account = agentToSessionAccountOrThrow(agent) const gates = tryFetchGates(account.did, 'prefer-fresh-gates') const moderation = configureModerationForAccount(agent, account) - if (!account.deactivated) { + if (!account.signupQueued) { /*dont await*/ agent.upsertProfile(_existing => { return { displayName: '', @@ -234,7 +234,9 @@ export function agentToSessionAccount( emailAuthFactor: agent.session.emailAuthFactor || false, refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, - deactivated: isSessionDeactivated(agent.session.accessJwt), + signupQueued: isSignupQueued(agent.session.accessJwt), + // @ts-expect-error TODO remove when backend is ready + status: agent.session.status || 'active', pdsUrl: agent.pdsUrl?.toString(), } } diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index e38dd2bb55..371bd459ad 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -17,7 +17,7 @@ import { } from './agent' import {getInitialState, reducer} from './reducer' -export {isSessionDeactivated} from './util' +export {isSignupQueued} from './util' export type {SessionAccount} from '#/state/session/types' import {SessionApiContext, SessionStateContext} from '#/state/session/types' diff --git a/src/state/session/util.ts b/src/state/session/util.ts index 8948ecd6b9..3a5909e825 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -10,11 +10,12 @@ export function readLastActiveAccount() { return accounts.find(a => a.did === currentAccount?.did) } -export function isSessionDeactivated(accessJwt: string | undefined) { +export function isSignupQueued(accessJwt: string | undefined) { if (accessJwt) { const sessData = jwtDecode(accessJwt) return ( - hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' + hasProp(sessData, 'scope') && + sessData.scope === 'com.atproto.signupQueued' ) } return false diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 06f1e111a0..6dd248ca7e 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -18,7 +18,13 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' import {colors, gradients, s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' -import {isAndroid} from 'platform/detection' +import {isAndroid, isWeb} from 'platform/detection' +import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' +import {atoms as a, useTheme as useNewTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {InlineLinkText} from '#/components/Link' +import {Text as NewText} from '#/components/Typography' import {resetToTab} from '../../../Navigation' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' @@ -30,6 +36,7 @@ export const snapPoints = isAndroid ? ['90%'] : ['55%'] export function Component({}: {}) { const pal = usePalette('default') const theme = useTheme() + const t = useNewTheme() const {currentAccount} = useSession() const agent = useAgent() const {removeAccount} = useSessionApi() @@ -41,6 +48,7 @@ export function Component({}: {}) { const [password, setPassword] = React.useState('') const [isProcessing, setIsProcessing] = React.useState(false) const [error, setError] = React.useState('') + const deactivateAccountControl = useDialogControl() const onPressSendEmail = async () => { setError('') setIsProcessing(true) @@ -168,6 +176,50 @@ export function Component({}: {}) { )} + + + + + + + + You can also temporarily deactivate your account instead, + and reactivate it at any time. + {' '} + { + e.preventDefault() + deactivateAccountControl.open() + return false + }}> + Click here for more information. + + + + + + ) : ( <> diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index a647ea902d..d075cc6961 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -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' @@ -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 ( @@ -791,6 +797,29 @@ export function SettingsScreen({}: Props) { Export My Data
+ + + + + + + Deactivate my account + + + + } - if (hasSession && currentAccount?.deactivated) { - return + if (hasSession && currentAccount?.signupQueued) { + return } if (showLoggedOut) { return setShowLoggedOut(false)} /> } + if (currentAccount?.status === 'deactivated') { + return + } if (onboardingState.isActive) { return } From d0327342783f5357f22fbc6b3903b51843306930 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 4 Jun 2024 12:55:35 +0300 Subject: [PATCH 17/24] Composer - make bottom border more consistent when typing (#4343) * floor values * fix last line being obscured * Rm unnecessary runOnUI --------- Co-authored-by: Dan Abramov --- src/view/com/composer/Composer.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 93cc87fc82..b78dafc917 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -20,7 +20,6 @@ import { } from 'react-native-keyboard-controller' import Animated, { interpolateColor, - runOnUI, useAnimatedStyle, useSharedValue, withTiming, @@ -396,7 +395,7 @@ export const ComposePost = observer(function ComposePost({ testID="composePostView" behavior="padding" style={a.flex_1} - keyboardVerticalOffset={replyTo ? 110 : isAndroid ? 180 : 140}> + keyboardVerticalOffset={replyTo ? 115 : isAndroid ? 180 : 162}> = scrollViewHeight.value + contentHeight.value - contentOffset.value - 5 > scrollViewHeight.value ? 1 : 0, ) @@ -667,9 +666,8 @@ function useAnimatedBorders() { const scrollHandler = useAnimatedScrollHandler({ onScroll: event => { + 'worklet' hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0) - - // already on UI thread showHideBottomBorder({ newContentOffset: event.contentOffset.y, newContentHeight: event.contentSize.height, @@ -680,7 +678,8 @@ function useAnimatedBorders() { const onScrollViewContentSizeChange = useCallback( (_width: number, height: number) => { - runOnUI(showHideBottomBorder)({ + 'worklet' + showHideBottomBorder({ newContentHeight: height, }) }, @@ -689,7 +688,8 @@ function useAnimatedBorders() { const onScrollViewLayout = useCallback( (evt: LayoutChangeEvent) => { - runOnUI(showHideBottomBorder)({ + 'worklet' + showHideBottomBorder({ newScrollViewHeight: evt.nativeEvent.layout.height, }) }, From d918f8dc2a07ff6cd94a1b37c5358dc6e9f6452c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 4 Jun 2024 12:58:09 +0300 Subject: [PATCH 18/24] 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 --- src/view/com/composer/Composer.tsx | 13 ++----------- src/view/shell/Composer.web.tsx | 31 +++++++++++------------------- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b78dafc917..58ec65a883 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -117,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() @@ -400,12 +400,7 @@ export const ComposePost = observer(function ComposePost({ style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal> - + - + - + - + ) } @@ -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, From 2ffb98e22acd5f9266ee976601016345a19f5927 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 4 Jun 2024 13:03:43 +0300 Subject: [PATCH 19/24] allow nested quotes in DMs (#4345) --- src/components/dms/MessageItemEmbed.tsx | 2 +- src/view/com/util/post-embeds/QuoteEmbed.tsx | 41 +++++++++++--------- src/view/com/util/post-embeds/index.tsx | 13 ++----- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index dbdbe95b56..aefd62b9ac 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -14,7 +14,7 @@ let MessageItemEmbed = ({ return ( - + ) } diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index cdbdafc9ba..d7624b4310 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -2,7 +2,6 @@ import React from 'react' import { StyleProp, StyleSheet, - TextStyle, TouchableOpacity, View, ViewStyle, @@ -32,7 +31,7 @@ import {InfoCircleIcon} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' import {precacheProfile} from 'state/queries/profile' import {ComposerOptsQuote} from 'state/shell/composer' -import {atoms as a, flatten} from '#/alf' +import {atoms as a} from '#/alf' import {RichText} from '#/components/RichText' import {ContentHider} from '../../../../components/moderation/ContentHider' import {PostAlerts} from '../../../../components/moderation/PostAlerts' @@ -46,12 +45,12 @@ export function MaybeQuoteEmbed({ embed, onOpen, style, - textStyle, + allowNestedQuotes, }: { embed: AppBskyEmbedRecord.View onOpen?: () => void style?: StyleProp - textStyle?: StyleProp + allowNestedQuotes?: boolean }) { const pal = usePalette('default') if ( @@ -65,7 +64,7 @@ export function MaybeQuoteEmbed({ postRecord={embed.record.value} onOpen={onOpen} style={style} - textStyle={textStyle} + allowNestedQuotes={allowNestedQuotes} /> ) } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { @@ -95,13 +94,13 @@ function QuoteEmbedModerated({ postRecord, onOpen, style, - textStyle, + allowNestedQuotes, }: { viewRecord: AppBskyEmbedRecord.ViewRecord postRecord: AppBskyFeedPost.Record onOpen?: () => void style?: StyleProp - textStyle?: StyleProp + allowNestedQuotes?: boolean }) { const moderationOpts = useModerationOpts() const moderation = React.useMemo(() => { @@ -126,7 +125,7 @@ function QuoteEmbedModerated({ moderation={moderation} onOpen={onOpen} style={style} - textStyle={textStyle} + allowNestedQuotes={allowNestedQuotes} /> ) } @@ -136,13 +135,13 @@ export function QuoteEmbed({ moderation, onOpen, style, - textStyle, + allowNestedQuotes, }: { quote: ComposerOptsQuote moderation?: ModerationDecision onOpen?: () => void style?: StyleProp - textStyle?: StyleProp + allowNestedQuotes?: boolean }) { const queryClient = useQueryClient() const pal = usePalette('default') @@ -161,16 +160,20 @@ export function QuoteEmbed({ const embed = React.useMemo(() => { const e = quote.embeds?.[0] - if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) { + if (allowNestedQuotes) { return e - } else if ( - AppBskyEmbedRecordWithMedia.isView(e) && - (AppBskyEmbedImages.isView(e.media) || - AppBskyEmbedExternal.isView(e.media)) - ) { - return e.media + } else { + if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) { + return e + } else if ( + AppBskyEmbedRecordWithMedia.isView(e) && + (AppBskyEmbedImages.isView(e.media) || + AppBskyEmbedExternal.isView(e.media)) + ) { + return e.media + } } - }, [quote.embeds]) + }, [quote.embeds, allowNestedQuotes]) const onBeforePress = React.useCallback(() => { precacheProfile(queryClient, quote.author) @@ -201,7 +204,7 @@ export function QuoteEmbed({ {richText ? ( diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 962f3d8c51..a13fffc370 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -4,7 +4,6 @@ import { StyleProp, StyleSheet, Text, - TextStyle, View, ViewStyle, } from 'react-native' @@ -42,13 +41,13 @@ export function PostEmbeds({ moderation, onOpen, style, - quoteTextStyle, + allowNestedQuotes, }: { embed?: Embed moderation?: ModerationDecision onOpen?: () => void style?: StyleProp - quoteTextStyle?: StyleProp + allowNestedQuotes?: boolean }) { const pal = usePalette('default') const {openLightbox} = useLightboxControls() @@ -63,11 +62,7 @@ export function PostEmbeds({ moderation={moderation} onOpen={onOpen} /> - +
) } @@ -98,8 +93,8 @@ export function PostEmbeds({ ) } From 6f1589971cd6b7a4d63c8a11374305d9d4790c33 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 4 Jun 2024 11:07:11 +0100 Subject: [PATCH 20/24] Fix missing top borders (#4346) --- src/view/com/feeds/ProfileFeedgens.tsx | 4 ++-- src/view/com/lists/ProfileLists.tsx | 4 ++-- src/view/com/posts/Feed.tsx | 10 +--------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 5977e6af99..197f35e4d0 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -14,7 +14,7 @@ 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' @@ -166,7 +166,7 @@ export const ProfileFeedgens = React.forwardRef< preferences={preferences} style={styles.item} showLikes - hideTopBorder={index === 0} + hideTopBorder={index === 0 && !isWeb} /> ) } diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index 8c3a151fa8..e7fdfe4bd5 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -14,7 +14,7 @@ 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 {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' @@ -170,7 +170,7 @@ export const ProfileLists = React.forwardRef( list={item} testID={`list-${item.name}`} style={styles.item} - noBorder={index === 0} + noBorder={index === 0 && !isWeb} /> ) }, diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 681670cf7b..315286e72a 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -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(Date.now()) const [feedType, feedUri] = feed.split('|') - const {isTabletOrMobile} = useWebMediaQueries() const opts = React.useMemo( () => ({enabled, ignoreFilterFor}), @@ -314,15 +312,9 @@ let Feed = ({ // -prf return } - return ( - - ) + return }, [ - isTabletOrMobile, renderEmptyState, feed, error, From e7968bc8d7d66d32feedff3401745578abe11e1d Mon Sep 17 00:00:00 2001 From: Ryan Skinner Date: Tue, 4 Jun 2024 11:31:24 -0400 Subject: [PATCH 21/24] 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 Co-authored-by: Dan Abramov --- src/view/screens/Search/Search.tsx | 202 ++++++++++++++++++++++++++++- 1 file changed, 195 insertions(+), 7 deletions(-) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index b6680176bf..003f9a8ba6 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -1,8 +1,11 @@ import React from 'react' import { ActivityIndicator, + Image, + ImageStyle, Platform, Pressable, + StyleProp, StyleSheet, TextInput, View, @@ -18,9 +21,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 +51,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 +494,9 @@ export function SearchScreen( const [showAutocomplete, setShowAutocomplete] = React.useState(false) const [searchHistory, setSearchHistory] = React.useState([]) + const [selectedProfiles, setSelectedProfiles] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) useFocusEffect( useNonReactiveCallback(() => { @@ -504,6 +513,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 +575,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 +635,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 +676,22 @@ export function SearchScreen( [searchHistory], ) + const handleRemoveProfile = React.useCallback( + (profileToRemove: AppBskyActorDefs.ProfileViewBasic) => { + const updatedProfiles = selectedProfiles.filter( + profile => profile.did !== profileToRemove.did, + ) + setSelectedProfiles(updatedProfiles) + AsyncStorage.setItem( + 'selectedProfiles', + JSON.stringify(updatedProfiles), + ).catch(e => { + logger.error('Failed to update selected profiles', {message: e}) + }) + }, + [selectedProfiles], + ) + return ( ) : ( )} @@ -814,12 +881,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 +919,10 @@ let AutocompleteResults = ({ key={item.did} profile={item} moderation={moderateProfile(item, moderationOpts)} - onPress={onResultPress} + onPress={() => { + onProfileClick(item) + onResultPress() + }} /> ))} @@ -861,17 +933,31 @@ 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') + return ( + {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( + + Recent Searches + + )} + {selectedProfiles.length > 0 && ( + + + {selectedProfiles.slice(0, 5).map((profile, index) => ( + + onProfileClick(profile)} + style={styles.profilePressable}> + } + accessibilityIgnoresInvertColors + /> + + {truncateText(profile.displayName || '', 12)} + + + onRemoveProfileClick(profile)} + hitSlop={createHitslop(6)} + style={styles.profileRemoveBtn}> + + + + ))} + + + )} {searchHistory.length > 0 && ( - - Recent Searches - - {searchHistory.map((historyItem, index) => ( + {searchHistory.slice(0, 5).map((historyItem, index) => ( Date: Tue, 4 Jun 2024 18:36:00 +0100 Subject: [PATCH 22/24] Fix forwarded ref (#4348) --- src/view/com/util/Views.web.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index ffea9fe2e6..21998bfbdd 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -32,14 +32,17 @@ interface AddedProps { desktopFixedHeight?: boolean | number } -export const CenteredView = React.forwardRef(function CenteredView({ - style, - sideBorders, - topBorder, - ...props -}: React.PropsWithChildren< - ViewProps & {sideBorders?: boolean; topBorder?: boolean} ->) { +export const CenteredView = React.forwardRef(function CenteredView( + { + style, + sideBorders, + topBorder, + ...props + }: React.PropsWithChildren< + ViewProps & {sideBorders?: boolean; topBorder?: boolean} + >, + ref: React.Ref, +) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() if (!isMobile) { @@ -58,7 +61,7 @@ export const CenteredView = React.forwardRef(function CenteredView({ }) style = addStyle(style, pal.border) } - return + return }) export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl( From e4b4d854d67bdd5107102b629c265c41a522c1f2 Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 4 Jun 2024 11:06:31 -0700 Subject: [PATCH 23/24] use rngh scrollview in search horizontal list (#4350) --- src/view/screens/Search/Search.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 003f9a8ba6..c8438a3486 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -10,6 +10,7 @@ import { TextInput, View, } from 'react-native' +import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler' import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' import { FontAwesomeIcon, @@ -977,7 +978,7 @@ function SearchHistory({ styles.selectedProfilesContainer, isMobile && styles.selectedProfilesContainerMobile, ]}> - ))} - + )} {searchHistory.length > 0 && ( From d6b8313932a62c45230bf63a5c2f3b10f8314584 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Tue, 4 Jun 2024 19:15:28 +0100 Subject: [PATCH 24/24] Mark `accessibilityLabel` and `accessibilityHint` for translation (#4351) * mark `accessibilityLabel` and `accessibilityHint` for translation * lint * try again --- src/view/screens/Search/Search.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index c8438a3486..118a8be25b 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -958,6 +958,7 @@ function SearchHistory({ }) { const {isTabletOrDesktop, isMobile} = useWebMediaQueries() const pal = usePalette('default') + const {_} = useLingui() return ( onRemoveProfileClick(profile)} hitSlop={createHitslop(6)} style={styles.profileRemoveBtn}>