diff --git a/package.json b/package.json index 29e198c9..3e27a819 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.18", + "@atproto/api": "^0.12.20", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/App.native.tsx b/src/App.native.tsx index 322e944a..18461fdd 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -14,40 +14,40 @@ import * as SplashScreen from 'expo-splash-screen' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useIntentHandler} from '#/lib/hooks/useIntentHandler' +import {QueryProvider} from '#/lib/react-query' import { initialize, Provider as StatsigProvider, tryFetchGates, } from '#/lib/statsig/statsig' +import {s} from '#/lib/styles' +import {ThemeProvider} from '#/lib/ThemeContext' import {logger} from '#/logger' +import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' +import {Provider as DialogStateProvider} from '#/state/dialogs' +import {Provider as InvitesStateProvider} from '#/state/invites' +import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' +import {Provider as ModalStateProvider} from '#/state/modals' import {init as initPersistedState} from '#/state/persisted' +import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' -import {readLastActiveAccount} from '#/state/session/util' -import {useIntentHandler} from 'lib/hooks/useIntentHandler' -import {QueryProvider} from 'lib/react-query' -import {s} from 'lib/styles' -import {ThemeProvider} from 'lib/ThemeContext' -import {Provider as DialogStateProvider} from 'state/dialogs' -import {Provider as InvitesStateProvider} from 'state/invites' -import {Provider as LightboxStateProvider} from 'state/lightbox' -import {Provider as ModalStateProvider} from 'state/modals' -import {Provider as MutedThreadsProvider} from 'state/muted-threads' -import {Provider as PrefsStateProvider} from 'state/preferences' -import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' +import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import { Provider as SessionProvider, SessionAccount, useSession, useSessionApi, -} from 'state/session' -import {Provider as ShellStateProvider} from 'state/shell' -import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' -import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' -import {TestCtrls} from 'view/com/testing/TestCtrls' -import * as Toast from 'view/com/util/Toast' -import {Shell} from 'view/shell' +} from '#/state/session' +import {readLastActiveAccount} from '#/state/session/util' +import {Provider as ShellStateProvider} from '#/state/shell' +import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' +import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' +import {TestCtrls} from '#/view/com/testing/TestCtrls' +import * as Toast from '#/view/com/util/Toast' +import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as PortalProvider} from '#/components/Portal' @@ -112,10 +112,12 @@ function InnerApp() { - - - - + + + + + + @@ -154,21 +156,19 @@ function App() { - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 5c4dc4e6..6af3c7d6 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useIntentHandler} from '#/lib/hooks/useIntentHandler' +import {QueryProvider} from '#/lib/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' +import {ThemeProvider} from '#/lib/ThemeContext' import {logger} from '#/logger' +import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' +import {Provider as DialogStateProvider} from '#/state/dialogs' +import {Provider as InvitesStateProvider} from '#/state/invites' +import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' +import {Provider as ModalStateProvider} from '#/state/modals' import {init as initPersistedState} from '#/state/persisted' +import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' -import {readLastActiveAccount} from '#/state/session/util' -import {useIntentHandler} from 'lib/hooks/useIntentHandler' -import {QueryProvider} from 'lib/react-query' -import {ThemeProvider} from 'lib/ThemeContext' -import {Provider as DialogStateProvider} from 'state/dialogs' -import {Provider as InvitesStateProvider} from 'state/invites' -import {Provider as LightboxStateProvider} from 'state/lightbox' -import {Provider as ModalStateProvider} from 'state/modals' -import {Provider as MutedThreadsProvider} from 'state/muted-threads' -import {Provider as PrefsStateProvider} from 'state/preferences' -import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' +import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import { Provider as SessionProvider, SessionAccount, useSession, useSessionApi, -} from 'state/session' -import {Provider as ShellStateProvider} from 'state/shell' -import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' -import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' -import * as Toast from 'view/com/util/Toast' -import {ToastContainer} from 'view/com/util/Toast.web' -import {Shell} from 'view/shell/index' +} from '#/state/session' +import {readLastActiveAccount} from '#/state/session/util' +import {Provider as ShellStateProvider} from '#/state/shell' +import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' +import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' +import * as Toast from '#/view/com/util/Toast' +import {ToastContainer} from '#/view/com/util/Toast.web' +import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as PortalProvider} from '#/components/Portal' @@ -96,9 +96,11 @@ function InnerApp() { - - - + + + + + @@ -136,21 +138,19 @@ function App() { - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 67b89e26..5d4ba0e3 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -54,8 +54,8 @@ import {useModalControls} from './state/modals' import {useUnreadNotifications} from './state/queries/notifications/unread' import {useSession} from './state/session' import { - setEmailConfirmationRequested, shouldRequestEmailConfirmation, + snoozeEmailConfirmationPrompt, } from './state/shell/reminders' import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings' import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' @@ -585,7 +585,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { openModal({name: 'verify-email', showReminder: true}) - setEmailConfirmationRequested() + snoozeEmailConfirmationPrompt() } } diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx index 63f61ce8..7b861dc6 100644 --- a/src/components/KnownFollowers.tsx +++ b/src/components/KnownFollowers.tsx @@ -100,7 +100,15 @@ function KnownFollowersInner({ moderation, } }) - const count = cachedKnownFollowers.count + + // Does not have blocks applied. Always >= slices.length + const serverCount = cachedKnownFollowers.count + + /* + * We check above too, but here for clarity and a reminder to _check for + * valid indices_ + */ + if (slice.length === 0) return null return ( - {count > 2 ? ( - - Followed by{' '} - - {slice[0].profile.displayName} - - ,{' '} - - {slice[1].profile.displayName} - - , and{' '} - - - ) : count === 2 ? ( + {slice.length >= 2 ? ( + // 2-n followers, including blocks + serverCount > 2 ? ( + + Followed by{' '} + + {slice[0].profile.displayName} + + ,{' '} + + {slice[1].profile.displayName} + + , and{' '} + + + ) : ( + // only 2 + + Followed by{' '} + + {slice[0].profile.displayName} + {' '} + and{' '} + + {slice[1].profile.displayName} + + + ) + ) : serverCount > 1 ? ( + // 1-n followers, including blocks Followed by{' '} {slice[0].profile.displayName} {' '} and{' '} - - {slice[1].profile.displayName} - + ) : ( + // only 1 Followed by{' '} diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index fcdae0da..0354bfc4 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -18,8 +18,10 @@ import {Text} from '#/components/Typography' export function NewskieDialog({ profile, + disabled, }: { profile: AppBskyActorDefs.ProfileViewDetailed + disabled?: boolean }) { const {_} = useLingui() const moderationOpts = useModerationOpts() @@ -30,18 +32,20 @@ export function NewskieDialog({ const moderation = moderateProfile(profile, moderationOpts) return sanitizeDisplayName(name, moderation.ui('displayName')) }, [moderationOpts, profile]) + const [now] = React.useState(() => Date.now()) const timeAgo = useGetTimeAgo() const createdAt = profile.createdAt as string | undefined const daysOld = React.useMemo(() => { if (!createdAt) return Infinity - return differenceInSeconds(new Date(), new Date(createdAt)) / 86400 - }, [createdAt]) + return differenceInSeconds(now, new Date(createdAt)) / 86400 + }, [createdAt, now]) if (!createdAt || daysOld > 7) return null return ( + + )} + + ) } function Rule({ @@ -130,15 +174,15 @@ function Rule({ post, lists, }: { - rule: any + rule: ThreadgateSetting post: AppBskyFeedDefs.PostView lists: AppBskyGraphDefs.ListViewBasic[] | undefined }) { const pal = usePalette('default') - if (AppBskyFeedThreadgate.isMentionRule(rule)) { + if (rule.type === 'mention') { return mentioned users } - if (AppBskyFeedThreadgate.isFollowingRule(rule)) { + if (rule.type === 'following') { return ( users followed by{' '} @@ -151,7 +195,7 @@ function Rule({ ) } - if (AppBskyFeedThreadgate.isListRule(rule)) { + if (rule.type === 'list') { const list = lists?.find(l => l.uri === rule.list) if (list) { const listUrip = new AtUri(list.uri) diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 6b0c1776..e917ab1d 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -38,6 +38,7 @@ function ListImpl( { ListHeaderComponent, ListFooterComponent, + ListEmptyComponent, containWeb, contentContainerStyle, data, @@ -72,23 +73,35 @@ function ListImpl( ) } - let header: JSX.Element | null = null + const isEmpty = !data || data.length === 0 + + let headerComponent: JSX.Element | null = null if (ListHeaderComponent != null) { if (isValidElement(ListHeaderComponent)) { - header = ListHeaderComponent + headerComponent = ListHeaderComponent } else { // @ts-ignore Nah it's fine. - header = + headerComponent = } } - let footer: JSX.Element | null = null + let footerComponent: JSX.Element | null = null if (ListFooterComponent != null) { if (isValidElement(ListFooterComponent)) { - footer = ListFooterComponent + footerComponent = ListFooterComponent } else { // @ts-ignore Nah it's fine. - footer = + footerComponent = + } + } + + let emptyComponent: JSX.Element | null = null + if (ListEmptyComponent != null) { + if (isValidElement(ListEmptyComponent)) { + emptyComponent = ListEmptyComponent + } else { + // @ts-ignore Nah it's fine. + emptyComponent = } } @@ -323,36 +336,38 @@ function ListImpl( onVisibleChange={handleAboveTheFoldVisibleChange} style={[styles.aboveTheFoldDetector, {height: headerOffset}]} /> - {onStartReached && ( + {onStartReached && !isEmpty && ( )} - {header} - {(data as Array).map((item, index) => { - const key = keyExtractor!(item, index) - return ( - - key={key} - item={item} - index={index} - renderItem={renderItem} - extraData={extraData} - onItemSeen={onItemSeen} - disableContentVisibility={disableContentVisibility} - /> - ) - })} - {onEndReached && ( + {headerComponent} + {isEmpty + ? emptyComponent + : (data as Array)?.map((item, index) => { + const key = keyExtractor!(item, index) + return ( + + key={key} + item={item} + index={index} + renderItem={renderItem} + extraData={extraData} + onItemSeen={onItemSeen} + disableContentVisibility={disableContentVisibility} + /> + ) + })} + {onEndReached && !isEmpty && ( )} - {footer} + {footerComponent} ) diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index d939b316..a4958518 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -15,12 +15,14 @@ export function TimeElapsed({ const ago = useGetTimeAgo() const format = timeToString ?? ago const tick = useTickEveryMinute() - const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp)) + const [timeElapsed, setTimeAgo] = React.useState(() => + format(timestamp, tick), + ) const [prevTick, setPrevTick] = React.useState(tick) if (prevTick !== tick) { setPrevTick(tick) - setTimeAgo(format(timestamp)) + setTimeAgo(format(timestamp, tick)) } return children({timeElapsed}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 587b466a..c212ea4c 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' interface BaseUserAvatarProps { type?: UserAvatarType + shape?: 'circle' | 'square' size: number avatar?: string | null } @@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100 let DefaultAvatar = ({ type, + shape: overrideShape, size, }: { type: UserAvatarType + shape?: 'square' | 'circle' size: number }): React.ReactNode => { + const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') if (type === 'algo') { + // TODO: shape=circle // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( - + {finalShape === 'square' ? ( + + ) : ( + + )} ) } + // TODO: shape=square return ( { const pal = usePalette('default') const backgroundColor = pal.colors.backgroundLight + const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { - if (type === 'algo' || type === 'list' || type === 'labeler') { + if (finalShape === 'square') { return { width: size, height: size, @@ -182,7 +195,7 @@ let UserAvatar = ({ borderRadius: Math.floor(size / 2), backgroundColor, } - }, [type, size, backgroundColor]) + }, [finalShape, size, backgroundColor]) const alert = useMemo(() => { if (!moderation?.alert) { @@ -224,7 +237,7 @@ let UserAvatar = ({ ) : ( - + {alert} ) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 2486b73d..45e00e58 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -7,7 +7,7 @@ import { } from 'react-native' import * as Clipboard from 'expo-clipboard' import { - AppBskyActorDefs, + AppBskyFeedDefs, AppBskyFeedPost, AtUri, RichText as RichTextAPI, @@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' +import {Shadow} from '#/state/cache/post-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' -import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' import {useLanguagePrefs} from '#/state/preferences' import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' -import {usePostDeleteMutation} from '#/state/queries/post' +import { + usePostDeleteMutation, + useThreadMuteMutationQueue, +} from '#/state/queries/post' import {useSession} from '#/state/session' import {getCurrentRoute} from 'lib/routes/helpers' import {shareUrl} from 'lib/sharing' @@ -62,9 +65,7 @@ import * as Toast from '../Toast' let PostDropdownBtn = ({ testID, - postAuthor, - postCid, - postUri, + post, postFeedContext, record, richText, @@ -74,9 +75,7 @@ let PostDropdownBtn = ({ timestamp, }: { testID: string - postAuthor: AppBskyActorDefs.ProfileViewBasic - postCid: string - postUri: string + post: Shadow postFeedContext: string | undefined record: AppBskyFeedPost.Record richText: RichTextAPI @@ -92,8 +91,6 @@ let PostDropdownBtn = ({ const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl const langPrefs = useLanguagePrefs() - const mutedThreads = useMutedThreads() - const toggleThreadMute = useToggleThreadMute() const postDeleteMutation = usePostDeleteMutation() const hiddenPosts = useHiddenPosts() const {hidePost} = useHiddenPostsApi() @@ -107,9 +104,15 @@ let PostDropdownBtn = ({ const loggedOutWarningPromptControl = useDialogControl() const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() + const postUri = post.uri + const postCid = post.cid + const postAuthor = post.author const rootUri = record.reply?.root?.uri || postUri - const isThreadMuted = mutedThreads.includes(rootUri) + const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( + post, + rootUri, + ) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isAuthor = postAuthor.did === currentAccount?.did @@ -162,18 +165,22 @@ let PostDropdownBtn = ({ const onToggleThreadMute = React.useCallback(() => { try { - const muted = toggleThreadMute(rootUri) - if (muted) { + if (isThreadMuted) { + unmuteThread() + Toast.show(_(msg`You will now receive notifications for this thread`)) + } else { + muteThread() Toast.show( _(msg`You will no longer receive notifications for this thread`), ) - } else { - Toast.show(_(msg`You will now receive notifications for this thread`)) } - } catch (e) { - logger.error('Failed to toggle thread mute', {message: e}) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to toggle thread mute', {message: e}) + Toast.show(_(msg`Failed to toggle thread mute, please try again`)) + } } - }, [rootUri, toggleThreadMute, _]) + }, [isThreadMuted, unmuteThread, _, muteThread]) const onCopyPostText = React.useCallback(() => { const str = richTextToString(richText, true) diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index 12eef14f..bade2a44 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -2,39 +2,60 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {Image} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {atoms as a} from '#/alf' +import {Text} from '#/components/Typography' interface Props { images: AppBskyEmbedImages.ViewImage[] style?: StyleProp + gif?: boolean } -export function ImageHorzList({images, style}: Props) { +export function ImageHorzList({images, style, gif}: Props) { return ( - + {images.map(({thumb, alt}) => ( - + style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}> + + {gif && ( + + + GIF + + + )} + ))} ) } const styles = StyleSheet.create({ - flexRow: { - flexDirection: 'row', - gap: 5, + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + right: 5, + bottom: 5, + zIndex: 2, }, - image: { - maxWidth: 100, - aspectRatio: 1, - flex: 1, - borderRadius: 4, + alt: { + color: 'white', + fontSize: 7, + fontWeight: 'bold', }, }) diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index c389855e..c0e743db 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -319,9 +319,7 @@ let PostCtrls = ({ ([]) const timeAgo = useGetTimeAgo() + const tick = useTickEveryMinute() useFocusEffect( React.useCallback(() => { @@ -72,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps< /> ) : undefined} - {timeAgo(entry.timestamp)} + {timeAgo(entry.timestamp, tick)} {expanded.includes(entry.id) ? ( diff --git a/yarn.lock b/yarn.lock index 51da5ea4..700ddfe0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.12.18": - version "0.12.18" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821" - integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ== +"@atproto/api@^0.12.20": + version "0.12.20" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3" + integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0"