From c75bb65bef1671e493f10e06b51ee4d0cda98d83 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 31 Jul 2024 13:00:22 +0100 Subject: [PATCH 1/9] Remove unused NoopFeedTuner (#4856) --- src/lib/api/feed-manip.ts | 10 ---------- src/state/queries/post-feed.ts | 27 ++++++--------------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 89f6a0bb45..226dd17c41 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -136,16 +136,6 @@ export class FeedViewPostsSlice { } } -export class NoopFeedTuner { - reset() {} - tune( - feed: FeedViewPost[], - _opts?: {dryRun: boolean; maintainOrder: boolean}, - ): FeedViewPostsSlice[] { - return feed.map(item => new FeedViewPostsSlice(item)) - } -} - export class FeedTuner { seenKeys: Set = new Set() seenUris: Set = new Set() diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 1d6ec80d91..569c85c3ad 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes' import {ListFeedAPI} from 'lib/api/feed/list' import {MergeFeedAPI} from 'lib/api/feed/merge' import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' -import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' +import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip' import {BSKY_FEED_OWNER_DIDS} from 'lib/constants' import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' @@ -61,7 +61,6 @@ export type FeedDescriptor = | `list|${ListUri}` | `list|${ListUri}|${ListFilter}` export interface FeedParams { - disableTuner?: boolean mergeFeedEnabled?: boolean mergeFeedSources?: string[] } @@ -105,7 +104,7 @@ export interface FeedPageUnselected { export interface FeedPage { api: FeedAPI - tuner: FeedTuner | NoopFeedTuner + tuner: FeedTuner cursor: string | undefined slices: FeedPostSlice[] fetchedAt: number @@ -142,18 +141,11 @@ export function usePostFeedQuery( const selectArgs = React.useMemo( () => ({ feedTuners, - disableTuner: params?.disableTuner, moderationOpts, ignoreFilterFor: opts?.ignoreFilterFor, isDiscover, }), - [ - feedTuners, - params?.disableTuner, - moderationOpts, - opts?.ignoreFilterFor, - isDiscover, - ], + [feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover], ) const query = useInfiniteQuery< @@ -232,17 +224,10 @@ export function usePostFeedQuery( (data: InfiniteData) => { // If the selection depends on some data, that data should // be included in the selectArgs object and read here. - const { - feedTuners, - disableTuner, - moderationOpts, - ignoreFilterFor, - isDiscover, - } = selectArgs + const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} = + selectArgs - const tuner = disableTuner - ? new NoopFeedTuner() - : new FeedTuner(feedTuners) + const tuner = new FeedTuner(feedTuners) // Keep track of the last run and whether we can reuse // some already selected pages from there. From 576cef88b550bacba26988a53c28fcc31bc9f8c5 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 31 Jul 2024 19:10:24 +0100 Subject: [PATCH 2/9] [Web] Retrigger onEndReached if needed when content height changes (#4859) * Extract EdgeVisibility * Key Visibility by container height instead of item count --- src/view/com/util/List.web.tsx | 35 +++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 12d223db03..5aa699356d 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -344,10 +344,11 @@ function ListImpl( style={[styles.aboveTheFoldDetector, {height: headerOffset}]} /> {onStartReached && !isEmpty && ( - )} {headerComponent} @@ -368,11 +369,11 @@ function ListImpl( ) })} {onEndReached && !isEmpty && ( - )} {footerComponent} @@ -381,6 +382,34 @@ function ListImpl( ) } +function EdgeVisibility({ + root, + topMargin, + bottomMargin, + containerRef, + onVisibleChange, +}: { + root?: React.RefObject | null + topMargin?: string + bottomMargin?: string + containerRef: React.RefObject + onVisibleChange: (isVisible: boolean) => void +}) { + const [containerHeight, setContainerHeight] = React.useState(0) + useResizeObserver(containerRef, (w, h) => { + setContainerHeight(h) + }) + return ( + + ) +} + function useResizeObserver( ref: React.RefObject, onResize: undefined | ((w: number, h: number) => void), From 70ffd387e3fc9c08076e9ff5f6df33fa86db8151 Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 31 Jul 2024 11:16:14 -0700 Subject: [PATCH 3/9] Only show "followed you back" when appropriate (#4849) * only show followed back when we should * try/catch * log * Update FeedItem.tsx * tweak --- src/view/com/notifications/FeedItem.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 520a059ae2..e4294eaa5f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -13,11 +13,13 @@ import { AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, + AppBskyGraphFollow, moderateProfile, ModerationDecision, ModerationOpts, } from '@atproto/api' import {AtUri} from '@atproto/api' +import {TID} from '@atproto/common-web' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -184,10 +186,28 @@ let FeedItem = ({ action = _(msg`reposted your post`) icon = } else if (item.type === 'follow') { + let isFollowBack = false + if ( item.notification.author.viewer?.following && - gate('ungroup_follow_backs') + AppBskyGraphFollow.isRecord(item.notification.record) ) { + let followingTimestamp + try { + const rkey = new AtUri(item.notification.author.viewer.following).rkey + followingTimestamp = TID.fromStr(rkey).timestamp() + } catch (e) { + // For some reason the following URI was invalid. Default to it not being a follow back. + console.error('Invalid following URI') + } + if (followingTimestamp) { + const followedTimestamp = + new Date(item.notification.record.createdAt).getTime() * 1000 + isFollowBack = followedTimestamp > followingTimestamp + } + } + + if (isFollowBack && gate('ungroup_follow_backs')) { action = _(msg`followed you back`) } else { action = _(msg`followed you`) From d2e88cc623b2df5fe40280618fe9598334df8241 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 1 Aug 2024 02:27:25 +0100 Subject: [PATCH 4/9] Fetch enough pages to fill a page's worth of items (#4863) * Fetch enough pages to fill a page's worth of items * Add failsafe in case of appview bug --- src/state/queries/notifications/feed.ts | 63 +++++++++++++++++-------- src/state/queries/post-feed.ts | 63 +++++++++++++++++-------- 2 files changed, 86 insertions(+), 40 deletions(-) diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 3cafcb7168..3054860db2 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -59,7 +59,6 @@ export function useNotificationFeedQuery(opts?: { const moderationOpts = useModerationOpts() const unreads = useUnreadNotificationsApi() const enabled = opts?.enabled !== false - const lastPageCountRef = useRef(0) const gate = useGate() // false: force showing all notifications @@ -121,28 +120,52 @@ export function useNotificationFeedQuery(opts?: { }, }) + // The server may end up returning an empty page, a page with too few items, + // or a page with items that end up getting filtered out. When we fetch pages, + // we'll keep track of how many items we actually hope to see. If the server + // doesn't return enough items, we're going to continue asking for more items. + const lastItemCount = useRef(0) + const wantedItemCount = useRef(0) + const autoPaginationAttemptCount = useRef(0) useEffect(() => { - const {isFetching, hasNextPage, data} = query - if (isFetching || !hasNextPage) { - return - } - - // avoid double-fires of fetchNextPage() - if ( - lastPageCountRef.current !== 0 && - lastPageCountRef.current === data?.pages?.length - ) { - return - } - - // fetch next page if we haven't gotten a full page of content - let count = 0 + const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} = + query + // Count the items that we already have. + let itemCount = 0 for (const page of data?.pages || []) { - count += page.items.length + itemCount += page.items.length } - if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) { - query.fetchNextPage() - lastPageCountRef.current = data?.pages?.length || 0 + + // If items got truncated, reset the state we're tracking below. + if (itemCount !== lastItemCount.current) { + if (itemCount < lastItemCount.current) { + wantedItemCount.current = itemCount + } + lastItemCount.current = itemCount + } + + // Now track how many items we really want, and fetch more if needed. + if (isLoading || isRefetching) { + // During the initial fetch, we want to get an entire page's worth of items. + wantedItemCount.current = PAGE_SIZE + } else if (isFetchingNextPage) { + if (itemCount > wantedItemCount.current) { + // We have more items than wantedItemCount, so wantedItemCount must be out of date. + // Some other code must have called fetchNextPage(), for example, from onEndReached. + // Adjust the wantedItemCount to reflect that we want one more full page of items. + wantedItemCount.current = itemCount + PAGE_SIZE + } + } else if (hasNextPage) { + // At this point we're not fetching anymore, so it's time to make a decision. + // If we didn't receive enough items from the server, paginate again until we do. + if (itemCount < wantedItemCount.current) { + autoPaginationAttemptCount.current++ + if (autoPaginationAttemptCount.current < 50 /* failsafe */) { + query.fetchNextPage() + } + } else { + autoPaginationAttemptCount.current = 0 + } } }, [query]) diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 569c85c3ad..65467e8023 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -134,7 +134,6 @@ export function usePostFeedQuery( args: typeof selectArgs result: InfiniteData } | null>(null) - const lastPageCountRef = useRef(0) const isDiscover = feedDesc.includes(DISCOVER_FEED_URI) // Make sure this doesn't invalidate unless really needed. @@ -376,30 +375,54 @@ export function usePostFeedQuery( ), }) + // The server may end up returning an empty page, a page with too few items, + // or a page with items that end up getting filtered out. When we fetch pages, + // we'll keep track of how many items we actually hope to see. If the server + // doesn't return enough items, we're going to continue asking for more items. + const lastItemCount = useRef(0) + const wantedItemCount = useRef(0) + const autoPaginationAttemptCount = useRef(0) useEffect(() => { - const {isFetching, hasNextPage, data} = query - if (isFetching || !hasNextPage) { - return - } - - // avoid double-fires of fetchNextPage() - if ( - lastPageCountRef.current !== 0 && - lastPageCountRef.current === data?.pages?.length - ) { - return - } - - // fetch next page if we haven't gotten a full page of content - let count = 0 + const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} = + query + // Count the items that we already have. + let itemCount = 0 for (const page of data?.pages || []) { for (const slice of page.slices) { - count += slice.items.length + itemCount += slice.items.length } } - if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) { - query.fetchNextPage() - lastPageCountRef.current = data?.pages?.length || 0 + + // If items got truncated, reset the state we're tracking below. + if (itemCount !== lastItemCount.current) { + if (itemCount < lastItemCount.current) { + wantedItemCount.current = itemCount + } + lastItemCount.current = itemCount + } + + // Now track how many items we really want, and fetch more if needed. + if (isLoading || isRefetching) { + // During the initial fetch, we want to get an entire page's worth of items. + wantedItemCount.current = PAGE_SIZE + } else if (isFetchingNextPage) { + if (itemCount > wantedItemCount.current) { + // We have more items than wantedItemCount, so wantedItemCount must be out of date. + // Some other code must have called fetchNextPage(), for example, from onEndReached. + // Adjust the wantedItemCount to reflect that we want one more full page of items. + wantedItemCount.current = itemCount + PAGE_SIZE + } + } else if (hasNextPage) { + // At this point we're not fetching anymore, so it's time to make a decision. + // If we didn't receive enough items from the server, paginate again until we do. + if (itemCount < wantedItemCount.current) { + autoPaginationAttemptCount.current++ + if (autoPaginationAttemptCount.current < 50 /* failsafe */) { + query.fetchNextPage() + } + } else { + autoPaginationAttemptCount.current = 0 + } } }, [query]) From b0e130a4d85f2056bddcbf210aa7ea4068d41686 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 1 Aug 2024 10:29:27 -0500 Subject: [PATCH 5/9] Update muted words dialog with `expiresAt` and `actorTarget` (#4801) * WIP not working dropdown * Update MutedWords dialog * Add i18n formatDistance * Comments * Handle text wrapping * Update label copy Co-authored-by: Hailey * Fix alignment * Improve translation output * Revert toggle changes * Better types for useFormatDistance * Tweaks * Integrate new sdk version into TagMenu * Use ampersand Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Bump SDK --------- Co-authored-by: Hailey Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> --- package.json | 2 +- src/components/TagMenu/index.tsx | 56 ++-- src/components/TagMenu/index.web.tsx | 36 ++- src/components/dialogs/MutedWords.tsx | 373 +++++++++++++++++++------ src/components/hooks/dates.ts | 69 +++++ src/state/queries/preferences/index.ts | 15 + yarn.lock | 8 +- 7 files changed, 432 insertions(+), 127 deletions(-) create mode 100644 src/components/hooks/dates.ts diff --git a/package.json b/package.json index 91b427ae91..3d053bc83b 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "0.12.25", + "@atproto/api": "^0.12.26", "@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/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 0ed7036671..2c6a0b674c 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -1,27 +1,27 @@ import React from 'react' import {View} from 'react-native' -import {useNavigation} from '@react-navigation/native' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' -import {atoms as a, native, useTheme} from '#/alf' -import * as Dialog from '#/components/Dialog' -import {Text} from '#/components/Typography' -import {Button, ButtonText} from '#/components/Button' -import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' -import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' -import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' -import {Divider} from '#/components/Divider' -import {Link} from '#/components/Link' import {makeSearchLink} from '#/lib/routes/links' import {NavigationProp} from '#/lib/routes/types' +import {isInvalidHandle} from '#/lib/strings/handles' import { usePreferencesQuery, + useRemoveMutedWordsMutation, useUpsertMutedWordsMutation, - useRemoveMutedWordMutation, } from '#/state/queries/preferences' +import {atoms as a, native, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import {Link} from '#/components/Link' import {Loader} from '#/components/Loader' -import {isInvalidHandle} from '#/lib/strings/handles' +import {Text} from '#/components/Typography' export function useTagMenuControl() { return Dialog.useDialogControl() @@ -52,10 +52,10 @@ export function TagMenu({ reset: resetUpsert, } = useUpsertMutedWordsMutation() const { - mutateAsync: removeMutedWord, + mutateAsync: removeMutedWords, variables: optimisticRemove, reset: resetRemove, - } = useRemoveMutedWordMutation() + } = useRemoveMutedWordsMutation() const displayTag = '#' + tag const isMuted = Boolean( @@ -65,9 +65,20 @@ export function TagMenu({ optimisticUpsert?.find( m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === tag), + !optimisticRemove?.find(m => m?.value === tag), ) + /* + * Mute word records that exactly match the tag in question. + */ + const removeableMuteWords = React.useMemo(() => { + return ( + preferences?.moderationPrefs.mutedWords?.filter(word => { + return word.value === tag + }) || [] + ) + }, [tag, preferences?.moderationPrefs?.mutedWords]) + return ( <> {children} @@ -212,13 +223,16 @@ export function TagMenu({ control.close(() => { if (isMuted) { resetUpsert() - removeMutedWord({ - value: tag, - targets: ['tag'], - }) + removeMutedWords(removeableMuteWords) } else { resetRemove() - upsertMutedWord([{value: tag, targets: ['tag']}]) + upsertMutedWord([ + { + value: tag, + targets: ['tag'], + actorTarget: 'all', + }, + ]) } }) }}> diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index 4336223861..b6c306439a 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -3,16 +3,16 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {isInvalidHandle} from '#/lib/strings/handles' -import {EventStopper} from '#/view/com/util/EventStopper' -import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' import {NavigationProp} from '#/lib/routes/types' +import {isInvalidHandle} from '#/lib/strings/handles' +import {enforceLen} from '#/lib/strings/helpers' import { usePreferencesQuery, + useRemoveMutedWordsMutation, useUpsertMutedWordsMutation, - useRemoveMutedWordMutation, } from '#/state/queries/preferences' -import {enforceLen} from '#/lib/strings/helpers' +import {EventStopper} from '#/view/com/util/EventStopper' +import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' import {web} from '#/alf' import * as Dialog from '#/components/Dialog' @@ -47,8 +47,8 @@ export function TagMenu({ const {data: preferences} = usePreferencesQuery() const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = useUpsertMutedWordsMutation() - const {mutateAsync: removeMutedWord, variables: optimisticRemove} = - useRemoveMutedWordMutation() + const {mutateAsync: removeMutedWords, variables: optimisticRemove} = + useRemoveMutedWordsMutation() const isMuted = Boolean( (preferences?.moderationPrefs.mutedWords?.find( m => m.value === tag && m.targets.includes('tag'), @@ -56,10 +56,21 @@ export function TagMenu({ optimisticUpsert?.find( m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === tag), + !optimisticRemove?.find(m => m?.value === tag), ) const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') + /* + * Mute word records that exactly match the tag in question. + */ + const removeableMuteWords = React.useMemo(() => { + return ( + preferences?.moderationPrefs.mutedWords?.filter(word => { + return word.value === tag + }) || [] + ) + }, [tag, preferences?.moderationPrefs?.mutedWords]) + const dropdownItems = React.useMemo(() => { return [ { @@ -105,9 +116,11 @@ export function TagMenu({ : _(msg`Mute ${truncatedTag}`), onPress() { if (isMuted) { - removeMutedWord({value: tag, targets: ['tag']}) + removeMutedWords(removeableMuteWords) } else { - upsertMutedWord([{value: tag, targets: ['tag']}]) + upsertMutedWord([ + {value: tag, targets: ['tag'], actorTarget: 'all'}, + ]) } }, testID: 'tagMenuMute', @@ -129,7 +142,8 @@ export function TagMenu({ tag, truncatedTag, upsertMutedWord, - removeMutedWord, + removeMutedWords, + removeableMuteWords, ]) return ( diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 526652be95..38273aad54 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {Keyboard, View} from 'react-native' +import {View} from 'react-native' import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' +import {useFormatDistance} from '#/components/hooks/dates' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' @@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' +const ONE_DAY = 24 * 60 * 60 * 1000 + export function MutedWordsDialog() { const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() return ( @@ -53,16 +56,32 @@ function MutedWordsInner() { } = usePreferencesQuery() const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() const [field, setField] = React.useState('') - const [options, setOptions] = React.useState(['content']) + const [targets, setTargets] = React.useState(['content']) const [error, setError] = React.useState('') + const [durations, setDurations] = React.useState(['forever']) + const [excludeFollowing, setExcludeFollowing] = React.useState(false) const submit = React.useCallback(async () => { const sanitizedValue = sanitizeMutedWordValue(field) - const targets = ['tag', options.includes('content') && 'content'].filter( + const surfaces = ['tag', targets.includes('content') && 'content'].filter( Boolean, ) as AppBskyActorDefs.MutedWord['targets'] + const actorTarget = excludeFollowing ? 'exclude-following' : 'all' - if (!sanitizedValue || !targets.length) { + const now = Date.now() + const rawDuration = durations.at(0) + // undefined evaluates to 'forever' + let duration: string | undefined + + if (rawDuration === '24_hours') { + duration = new Date(now + ONE_DAY).toISOString() + } else if (rawDuration === '7_days') { + duration = new Date(now + 7 * ONE_DAY).toISOString() + } else if (rawDuration === '30_days') { + duration = new Date(now + 30 * ONE_DAY).toISOString() + } + + if (!sanitizedValue || !surfaces.length) { setField('') setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) return @@ -70,28 +89,37 @@ function MutedWordsInner() { try { // send raw value and rely on SDK as sanitization source of truth - await addMutedWord([{value: field, targets}]) + await addMutedWord([ + { + value: field, + targets: surfaces, + actorTarget, + expiresAt: duration, + }, + ]) setField('') } catch (e: any) { logger.error(`Failed to save muted word`, {message: e.message}) setError(e.message) } - }, [_, field, options, addMutedWord, setField]) + }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) return ( - + Add muted words and tags - Posts can be muted based on their text, their tags, or both. + Posts can be muted based on their text, their tags, or both. We + recommend avoiding common words that appear in many posts, since it + can result in no posts being shown. - + + + + values={durations} + onChange={setDurations}> + + Duration: + + + + + + + + + Forever + + + + + + + + + + + 24 hours + + + + + + + + + + + + + 7 days + + + + + + + + + + + 30 days + + + + + + + + + + + Mute in: + + + + style={[a.flex_1]}> - + - - Mute in text & tags + + Text & tags @@ -140,34 +273,64 @@ function MutedWordsInner() { + style={[a.flex_1]}> - + - - Mute in tags only + + Tags only - - + + + Options: + + + + + + + Exclude users you follow + + + + + + + + + + {error && ( )} - - - - We recommend avoiding common words that appear in many posts, - since it can result in no posts being shown. - - @@ -268,6 +417,9 @@ function MutedWordRow({ const {_} = useLingui() const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() const control = Prompt.usePromptControl() + const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined + const isExpired = expiryDate && expiryDate < new Date() + const formatDistance = useFormatDistance() const remove = React.useCallback(async () => { control.close() @@ -280,7 +432,7 @@ function MutedWordRow({ control={control} title={_(msg`Are you sure?`)} description={_( - msg`This will delete ${word.value} from your muted words. You can always add it back later.`, + msg`This will delete "${word.value}" from your muted words. You can always add it back later.`, )} onConfirm={remove} confirmButtonCta={_(msg`Remove`)} @@ -289,53 +441,94 @@ function MutedWordRow({ - - {word.value} - + + + + {word.targets.find(t => t === 'content') ? ( + + {word.value}{' '} + + in{' '} + + text & tags + + + + ) : ( + + {word.value}{' '} + + in{' '} + + tags + + + + )} + + - - {word.targets.map(target => ( - + {(expiryDate || word.actorTarget === 'exclude-following') && ( + - {target === 'content' ? _(msg`text`) : _(msg`tag`)} + style={[ + a.flex_1, + a.text_xs, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {expiryDate && ( + <> + {isExpired ? ( + Expired + ) : ( + + Expires{' '} + {formatDistance(expiryDate, new Date(), { + addSuffix: true, + })} + + )} + + )} + {word.actorTarget === 'exclude-following' && ( + <> + {' • '} + Excludes users you follow + + )} - ))} - - + )} + + ) diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts new file mode 100644 index 0000000000..b0f94133b7 --- /dev/null +++ b/src/components/hooks/dates.ts @@ -0,0 +1,69 @@ +/** + * Hooks for date-fns localized formatters. + * + * Our app supports some languages that are not included in date-fns by + * default, in which case it will fall back to English. + * + * {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md} + */ + +import React from 'react' +import {formatDistance, Locale} from 'date-fns' +import { + ca, + de, + es, + fi, + fr, + hi, + id, + it, + ja, + ko, + ptBR, + tr, + uk, + zhCN, + zhTW, +} from 'date-fns/locale' + +import {AppLanguage} from '#/locale/languages' +import {useLanguagePrefs} from '#/state/preferences' + +/** + * {@link AppLanguage} + */ +const locales: Record = { + en: undefined, + ca, + de, + es, + fi, + fr, + ga: undefined, + hi, + id, + it, + ja, + ko, + ['pt-BR']: ptBR, + tr, + uk, + ['zh-CN']: zhCN, + ['zh-TW']: zhTW, +} + +/** + * Returns a localized `formatDistance` function. + * {@link formatDistance} + */ +export function useFormatDistance() { + const {appLanguage} = useLanguagePrefs() + return React.useCallback( + (date, baseDate, options) => { + const locale = locales[appLanguage as AppLanguage] + return formatDistance(date, baseDate, {...options, locale: locale}) + }, + [appLanguage], + ) +} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 9bb57fcaf6..6991f8647b 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -343,6 +343,21 @@ export function useRemoveMutedWordMutation() { }) } +export function useRemoveMutedWordsMutation() { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { + await agent.removeMutedWords(mutedWords) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + export function useQueueNudgesMutation() { const queryClient = useQueryClient() const agent = useAgent() diff --git a/yarn.lock b/yarn.lock index 675fda4c2f..6fa8805125 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@0.12.25": - version "0.12.25" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b" - integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A== +"@atproto/api@^0.12.26": + version "0.12.26" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca" + integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0" From 388c157c366e67e0cb3d74e1cd05413ef41b235d Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 1 Aug 2024 17:49:43 +0100 Subject: [PATCH 6/9] Display second-to-last rather than second post in a slice (#4864) --- src/view/com/posts/FeedSlice.tsx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index b8c9a1f2c7..8d707d78ea 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -19,6 +19,7 @@ let FeedSlice = ({ hideTopBorder?: boolean }): React.ReactNode => { if (slice.isThread && slice.items.length > 3) { + const beforeLast = slice.items.length - 2 const last = slice.items.length - 1 return ( <> @@ -36,20 +37,20 @@ let FeedSlice = ({ hideTopBorder={hideTopBorder} isParentBlocked={slice.items[0].isParentBlocked} /> - + Date: Thu, 1 Aug 2024 19:14:32 +0200 Subject: [PATCH 7/9] Move theme controls to its own screen (#4866) --- assets/icons/moon_stroke2_corner2_rounded.svg | 1 + .../icons/phone_stroke2_corner2_rounded.svg | 1 + bskyweb/cmd/bskyweb/server.go | 1 + src/Navigation.tsx | 9 ++ src/components/forms/ToggleButton.tsx | 2 +- src/components/icons/Moon.tsx | 5 + src/components/icons/Phone.tsx | 5 + src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/screens/Settings/AppearanceSettings.tsx | 135 ++++++++++++++++++ src/view/icons/index.tsx | 2 + src/view/screens/AccessibilitySettings.tsx | 10 +- .../screens/PreferencesExternalEmbeds.tsx | 5 +- src/view/screens/PreferencesFollowingFeed.tsx | 5 +- src/view/screens/PreferencesThreads.tsx | 4 +- src/view/screens/Settings/index.tsx | 95 ++++-------- 16 files changed, 204 insertions(+), 78 deletions(-) create mode 100644 assets/icons/moon_stroke2_corner2_rounded.svg create mode 100644 assets/icons/phone_stroke2_corner2_rounded.svg create mode 100644 src/components/icons/Moon.tsx create mode 100644 src/components/icons/Phone.tsx create mode 100644 src/screens/Settings/AppearanceSettings.tsx diff --git a/assets/icons/moon_stroke2_corner2_rounded.svg b/assets/icons/moon_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..8f5c03699b --- /dev/null +++ b/assets/icons/moon_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/phone_stroke2_corner2_rounded.svg b/assets/icons/phone_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..4f44f08e52 --- /dev/null +++ b/assets/icons/phone_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 61a524a70b..8da291fe56 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -211,6 +211,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/threads", server.WebGeneric) e.GET("/settings/external-embeds", server.WebGeneric) e.GET("/settings/accessibility", server.WebGeneric) + e.GET("/settings/appearance", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/debug-mod", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 8646577c8b..79856879c3 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -44,6 +44,7 @@ import HashtagScreen from '#/screens/Hashtag' import {ModerationScreen} from '#/screens/Moderation' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import { StarterPackScreen, StarterPackScreenShort, @@ -310,6 +311,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { requireAuth: true, }} /> + AppearanceSettingsScreen} + options={{ + title: title(msg`Appearance Settings`), + requireAuth: true, + }} + /> HashtagScreen} diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 7528426380..f47a272b18 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) { style={[ a.w_full, a.flex_row, - a.border, a.rounded_sm, a.overflow_hidden, t.atoms.border_contrast_low, + {borderWidth: 1}, ]}> {children} diff --git a/src/components/icons/Moon.tsx b/src/components/icons/Moon.tsx new file mode 100644 index 0000000000..4994370b9e --- /dev/null +++ b/src/components/icons/Moon.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Moon_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z', +}) diff --git a/src/components/icons/Phone.tsx b/src/components/icons/Phone.tsx new file mode 100644 index 0000000000..62000a1e5d --- /dev/null +++ b/src/components/icons/Phone.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Phone_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z', +}) diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index fbb66c9e9a..0cc83b475a 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -38,6 +38,7 @@ export type CommonNavigatorParams = { PreferencesThreads: undefined PreferencesExternalEmbeds: undefined AccessibilitySettings: undefined + AppearanceSettings: undefined Search: {q?: string} Hashtag: {tag: string; author?: string} MessagesConversation: {conversation: string; embed?: string} diff --git a/src/routes.ts b/src/routes.ts index ddf4fb39fa..c9e23e08c8 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -32,6 +32,7 @@ export const router = new Router({ PreferencesThreads: '/settings/threads', PreferencesExternalEmbeds: '/settings/external-embeds', AccessibilitySettings: '/settings/accessibility', + AppearanceSettings: '/settings/appearance', SavedFeeds: '/settings/saved-feeds', Support: '/support', PrivacyPolicy: '/support/privacy', diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx new file mode 100644 index 0000000000..00a04bbfb6 --- /dev/null +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -0,0 +1,135 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import Animated, { + FadeInDown, + FadeOutDown, + LayoutAnimationConfig, +} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {s} from '#/lib/styles' +import {useSetThemePrefs, useThemePrefs} from '#/state/shell' +import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' +import {ScrollView} from '#/view/com/util/Views' +import {atoms as a, native, useTheme} from '#/alf' +import * as ToggleButton from '#/components/forms/ToggleButton' +import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' +import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' +import {Text} from '#/components/Typography' + +type Props = NativeStackScreenProps +export function AppearanceSettingsScreen({}: Props) { + const {_} = useLingui() + const t = useTheme() + const {isTabletOrMobile} = useWebMediaQueries() + + const {colorMode, darkTheme} = useThemePrefs() + const {setColorMode, setDarkTheme} = useSetThemePrefs() + + const onChangeAppearance = useCallback( + (keys: string[]) => { + const appearance = keys.find(key => key !== colorMode) as + | 'system' + | 'light' + | 'dark' + | undefined + if (!appearance) return + setColorMode(appearance) + }, + [setColorMode, colorMode], + ) + + const onChangeDarkTheme = useCallback( + (keys: string[]) => { + const theme = keys.find(key => key !== darkTheme) as + | 'dim' + | 'dark' + | undefined + if (!theme) return + setDarkTheme(theme) + }, + [setDarkTheme, darkTheme], + ) + + return ( + + + + + + + Appearance + + + + + + + + + Mode + + + + + + System + + + + + Light + + + + + Dark + + + + {colorMode !== 'light' && ( + + + + + Dark theme + + + + + + + Dim + + + + + Dark + + + + + )} + + + + + ) +} diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index beb31eca4e..8b1655e6a8 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -77,6 +77,7 @@ import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl' import {faLock} from '@fortawesome/free-solid-svg-icons/faLock' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky' +import {faPaintRoller} from '@fortawesome/free-solid-svg-icons/faPaintRoller' import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' import {faPen} from '@fortawesome/free-solid-svg-icons/faPen' import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' @@ -178,6 +179,7 @@ library.add( faMagnifyingGlass, faMessage, faNoteSticky, + faPaintRoller, faPaste, faPause, faPen, diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx index abe1550762..2a4477532d 100644 --- a/src/view/screens/AccessibilitySettings.tsx +++ b/src/view/screens/AccessibilitySettings.tsx @@ -27,6 +27,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' +import {atoms as a} from '#/alf' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -61,10 +62,13 @@ export function AccessibilitySettingsScreen({}: Props) { showBackButton={isTabletOrMobile} style={[ pal.border, - {borderBottomWidth: 1}, - !isMobile && {borderLeftWidth: 1, borderRightWidth: 1}, + a.border_b, + !isMobile && { + borderLeftWidth: StyleSheet.hairlineWidth, + borderRightWidth: StyleSheet.hairlineWidth, + }, ]}> - + Accessibility Settings diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx index 57ca5e7653..ade7a53d90 100644 --- a/src/view/screens/PreferencesExternalEmbeds.tsx +++ b/src/view/screens/PreferencesExternalEmbeds.tsx @@ -18,6 +18,7 @@ import { useSetExternalEmbedPref, } from 'state/preferences' import {ToggleButton} from 'view/com/util/forms/ToggleButton' +import {atoms as a} from '#/alf' import {SimpleViewHeader} from '../com/util/SimpleViewHeader' import {Text} from '../com/util/text/Text' import {ScrollView} from '../com/util/Views' @@ -47,8 +48,8 @@ export function PreferencesExternalEmbeds({}: Props) { contentContainerStyle={[pal.viewLight, {paddingBottom: 75}]}> - + style={[pal.border, a.border_b]}> + External Media Preferences diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx index 879c925fbf..daa2aba858 100644 --- a/src/view/screens/PreferencesFollowingFeed.tsx +++ b/src/view/screens/PreferencesFollowingFeed.tsx @@ -19,6 +19,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' +import {atoms as a} from '#/alf' function RepliesThresholdInput({ enabled, @@ -99,8 +100,8 @@ export function PreferencesFollowingFeed({}: Props) { contentContainerStyle={{paddingBottom: 75}}> - + style={[pal.border, a.border_b]}> + Following Feed Preferences diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx index 3b09f0abb5..4a311f91ce 100644 --- a/src/view/screens/PreferencesThreads.tsx +++ b/src/view/screens/PreferencesThreads.tsx @@ -45,8 +45,8 @@ export function PreferencesThreads({}: Props) { contentContainerStyle={{paddingBottom: 75}}> - + style={[pal.border, a.border_b]}> + Thread Preferences diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index db74d5c0d5..c33be7d542 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -31,12 +31,7 @@ import {useClearPreferencesMutation} from '#/state/queries/preferences' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile' import {SessionAccount, useSession, useSessionApi} from '#/state/session' -import { - useOnboardingDispatch, - useSetMinimalShellMode, - useSetThemePrefs, - useThemePrefs, -} from '#/state/shell' +import {useOnboardingDispatch, useSetMinimalShellMode} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {useAnalytics} from 'lib/analytics/analytics' @@ -52,7 +47,6 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types' import {colors, s} from 'lib/styles' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' -import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {Link, TextLink} from 'view/com/util/Link' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' @@ -61,8 +55,7 @@ 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 {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {navigate, resetToTab} from '#/Navigation' @@ -168,8 +161,6 @@ function SettingsAccountCard({ type Props = NativeStackScreenProps export function SettingsScreen({}: Props) { const queryClient = useQueryClient() - const {colorMode, darkTheme} = useThemePrefs() - const {setColorMode, setDarkTheme} = useSetThemePrefs() const pal = usePalette('default') const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() @@ -296,6 +287,10 @@ export function SettingsScreen({}: Props) { navigation.navigate('AccessibilitySettings') }, [navigation]) + const onPressAppearanceSettings = React.useCallback(() => { + navigation.navigate('AppearanceSettings') + }, [navigation]) + const onPressBirthday = React.useCallback(() => { birthdayControl.open() }, [birthdayControl]) @@ -436,63 +431,6 @@ export function SettingsScreen({}: Props) { - - Appearance - - - - setColorMode('system')} - accessibilityHint={_(msg`Sets color theme to system setting`)} - /> - setColorMode('light')} - accessibilityHint={_(msg`Sets color theme to light`)} - /> - setColorMode('dark')} - accessibilityHint={_(msg`Sets color theme to dark`)} - /> - - - - - - {colorMode !== 'light' && ( - <> - - Dark Theme - - - - setDarkTheme('dim')} - accessibilityHint={_(msg`Sets dark theme to the dim theme`)} - /> - setDarkTheme('dark')} - accessibilityHint={_(msg`Sets dark theme to the dark theme`)} - /> - - - - - )} - Basics @@ -519,6 +457,27 @@ export function SettingsScreen({}: Props) { Accessibility + + + + + + Appearance + + Date: Thu, 1 Aug 2024 10:32:36 -0700 Subject: [PATCH 8/9] Fix missing header on Likes/Reposted By, add missing perf optimizations (#4867) * fix liked by list * fix lists * tweaks to style * change string --- src/view/com/post-thread/PostLikedBy.tsx | 105 ++++++++++--------- src/view/com/post-thread/PostRepostedBy.tsx | 106 ++++++++++---------- src/view/screens/PostLikedBy.tsx | 15 +-- src/view/screens/PostRepostedBy.tsx | 17 ++-- src/view/screens/ProfileFeedLikedBy.tsx | 17 ++-- 5 files changed, 131 insertions(+), 129 deletions(-) diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 0760ed7ff3..da230aade9 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -1,38 +1,57 @@ import React, {useCallback, useMemo, useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' -import {CenteredView} from '../util/Views' -import {List} from '../util/List' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {logger} from '#/logger' -import {LoadingScreen} from '../util/LoadingScreen' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {List} from '../util/List' + +function renderItem({item}: {item: GetLikes.Like}) { + return +} + +function keyExtractor(item: GetLikes.Like) { + return item.actor.did +} export function PostLikedBy({uri}: {uri: string}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const [isPTRing, setIsPTRing] = useState(false) + const { data: resolvedUri, error: resolveError, - isFetching: isFetchingResolvedUri, + isLoading: isLoadingUri, } = useResolveUriQuery(uri) const { data, - isFetching, - isFetched, + isLoading: isLoadingLikes, isFetchingNextPage, hasNextPage, fetchNextPage, - isError, error, refetch, } = useLikedByQuery(resolvedUri?.uri) + + const isError = Boolean(resolveError || error) + const likes = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.likes) } + return [] }, [data]) const onRefresh = useCallback(async () => { @@ -46,64 +65,44 @@ export function PostLikedBy({uri}: {uri: string}) { }, [refetch, setIsPTRing]) const onEndReached = useCallback(async () => { - if (isFetching || !hasNextPage || isError) return + if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() } catch (err) { logger.error('Failed to load more likes', {message: err}) } - }, [isFetching, hasNextPage, isError, fetchNextPage]) + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - const renderItem = useCallback(({item}: {item: GetLikes.Like}) => { + if (likes.length < 1) { return ( - - ) - }, []) - - if (isFetchingResolvedUri || !isFetched) { - return - } - - // error - // = - if (resolveError || isError) { - return ( - - - + ) } - // loaded - // = return ( item.actor.did} + renderItem={renderItem} + keyExtractor={keyExtractor} refreshing={isPTRing} onRefresh={onRefresh} onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - - {(isFetching || isFetchingNextPage) && } - - )} + onEndReachedThreshold={4} + ListHeaderComponent={} + ListFooterComponent={ + + } // @ts-ignore our .web version only -prf desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} /> ) } - -const styles = StyleSheet.create({ - footer: { - height: 200, - paddingTop: 20, - }, -}) diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 31a0be832d..9038549a50 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -1,38 +1,57 @@ -import React, {useMemo, useCallback, useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' +import React, {useCallback, useMemo, useState} from 'react' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' -import {CenteredView} from '../util/Views' -import {List} from '../util/List' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {logger} from '#/logger' -import {LoadingScreen} from '../util/LoadingScreen' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {List} from '../util/List' + +function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { + return +} + +function keyExtractor(item: ActorDefs.ProfileViewBasic) { + return item.did +} export function PostRepostedBy({uri}: {uri: string}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const [isPTRing, setIsPTRing] = useState(false) + const { data: resolvedUri, error: resolveError, - isFetching: isFetchingResolvedUri, + isLoading: isLoadingUri, } = useResolveUriQuery(uri) const { data, - isFetching, - isFetched, + isLoading: isLoadingRepostedBy, isFetchingNextPage, hasNextPage, fetchNextPage, - isError, error, refetch, } = usePostRepostedByQuery(resolvedUri?.uri) + + const isError = Boolean(resolveError || error) + const repostedBy = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.repostedBy) } + return [] }, [data]) const onRefresh = useCallback(async () => { @@ -46,35 +65,20 @@ export function PostRepostedBy({uri}: {uri: string}) { }, [refetch, setIsPTRing]) const onEndReached = useCallback(async () => { - if (isFetching || !hasNextPage || isError) return + if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() } catch (err) { logger.error('Failed to load more reposts', {message: err}) } - }, [isFetching, hasNextPage, isError, fetchNextPage]) + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - const renderItem = useCallback( - ({item}: {item: ActorDefs.ProfileViewBasic}) => { - return - }, - [], - ) - - if (isFetchingResolvedUri || !isFetched) { - return - } - - // error - // = - if (resolveError || isError) { + if (repostedBy.length < 1) { return ( - - - + ) } @@ -83,28 +87,24 @@ export function PostRepostedBy({uri}: {uri: string}) { return ( item.did} + renderItem={renderItem} + keyExtractor={keyExtractor} refreshing={isPTRing} onRefresh={onRefresh} onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - - {(isFetching || isFetchingNextPage) && } - - )} + onEndReachedThreshold={4} + ListHeaderComponent={} + ListFooterComponent={ + + } // @ts-ignore our .web version only -prf desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} /> ) } - -const styles = StyleSheet.create({ - footer: { - height: 200, - paddingTop: 20, - }, -}) diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx index 604301544c..5ff5a1932e 100644 --- a/src/view/screens/PostLikedBy.tsx +++ b/src/view/screens/PostLikedBy.tsx @@ -1,13 +1,14 @@ import React from 'react' import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {useSetMinimalShellMode} from '#/state/shell' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' +import {ViewHeader} from '../com/util/ViewHeader' type Props = NativeStackScreenProps export const PostLikedByScreen = ({route}: Props) => { @@ -23,7 +24,7 @@ export const PostLikedByScreen = ({route}: Props) => { ) return ( - + diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index 07017d6920..eaacc67807 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -1,13 +1,14 @@ import React from 'react' import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {useSetMinimalShellMode} from '#/state/shell' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' +import {ViewHeader} from '../com/util/ViewHeader' type Props = NativeStackScreenProps export const PostRepostedByScreen = ({route}: Props) => { @@ -23,7 +24,7 @@ export const PostRepostedByScreen = ({route}: Props) => { ) return ( - + diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index b1bcf48ba4..bb9ec2baeb 100644 --- a/src/view/screens/ProfileFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -1,13 +1,14 @@ import React from 'react' import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {useSetMinimalShellMode} from '#/state/shell' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' +import {ViewHeader} from '../com/util/ViewHeader' type Props = NativeStackScreenProps export const ProfileFeedLikedByScreen = ({route}: Props) => { @@ -23,7 +24,7 @@ export const ProfileFeedLikedByScreen = ({route}: Props) => { ) return ( - + From 7f292abf51a4cd4e25702c33a3ed75f25be5b3a3 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 1 Aug 2024 22:05:40 +0100 Subject: [PATCH 9/9] Always limit Following replies to the people you follow (#4868) * Limit feed replies to people you follow * Remove dead code --- src/lib/api/feed-manip.ts | 14 +-- src/state/preferences/feed-tuners.tsx | 9 +- src/state/queries/preferences/const.ts | 4 +- src/view/screens/PreferencesFollowingFeed.tsx | 107 +----------------- 4 files changed, 8 insertions(+), 126 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 226dd17c41..01f05685dd 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -299,15 +299,7 @@ export class FeedTuner { return slices } - static thresholdRepliesOnly({ - userDid, - minLikes, - followedOnly, - }: { - userDid: string - minLikes: number - followedOnly: boolean - }) { + static followedRepliesOnly({userDid}: {userDid: string}) { return ( tuner: FeedTuner, slices: FeedViewPostsSlice[], @@ -322,9 +314,7 @@ export class FeedTuner { if (slice.isRepost) { continue } - if (slice.likeCount < minLikes) { - slices.splice(i, 1) - } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) { + if (!slice.isFollowingAllAuthors(userDid)) { slices.splice(i, 1) } } diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx index 7d44515138..d816bde649 100644 --- a/src/state/preferences/feed-tuners.tsx +++ b/src/state/preferences/feed-tuners.tsx @@ -38,11 +38,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { feedTuners.push(FeedTuner.removeReplies) } else { feedTuners.push( - FeedTuner.thresholdRepliesOnly({ + FeedTuner.followedRepliesOnly({ userDid: currentAccount?.did || '', - minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0, - followedOnly: - !!preferences?.feedViewPrefs.hideRepliesByUnfollowed, }), ) } @@ -66,10 +63,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { feedTuners.push(FeedTuner.removeReplies) } else { feedTuners.push( - FeedTuner.thresholdRepliesOnly({ + FeedTuner.followedRepliesOnly({ userDid: currentAccount?.did || '', - minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0, - followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed, }), ) } diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 2a8c51165e..1ae7d20684 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -7,8 +7,8 @@ import { export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = { hideReplies: false, - hideRepliesByUnfollowed: true, - hideRepliesByLikeCount: 0, + hideRepliesByUnfollowed: true, // Legacy, ignored + hideRepliesByLikeCount: 0, // Legacy, ignored hideReposts: false, hideQuotePosts: false, lab_mergeFeedEnabled: false, // experimental diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx index daa2aba858..8aa4221e6c 100644 --- a/src/view/screens/PreferencesFollowingFeed.tsx +++ b/src/view/screens/PreferencesFollowingFeed.tsx @@ -1,16 +1,13 @@ -import React, {useState} from 'react' +import React from 'react' import {StyleSheet, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Plural, Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Slider} from '@miblanchard/react-native-slider' -import debounce from 'lodash.debounce' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {colors, s} from '#/lib/styles' -import {isWeb} from '#/platform/detection' import { usePreferencesQuery, useSetFeedViewPreferencesMutation, @@ -21,61 +18,6 @@ import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' import {atoms as a} from '#/alf' -function RepliesThresholdInput({ - enabled, - initialValue, -}: { - enabled: boolean - initialValue: number -}) { - const pal = usePalette('default') - const [value, setValue] = useState(initialValue) - const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() - const preValue = React.useRef(initialValue) - const save = React.useMemo( - () => - debounce( - threshold => - setFeedViewPref({ - hideRepliesByLikeCount: threshold, - }), - 500, - ), // debouce for 500ms - [setFeedViewPref], - ) - - return ( - - { - let threshold = Array.isArray(v) ? v[0] : v - if (threshold > preValue.current) threshold = Math.floor(threshold) - else threshold = Math.ceil(threshold) - - preValue.current = threshold - - setValue(threshold) - save(threshold) - }} - minimumValue={0} - maximumValue={25} - containerStyle={isWeb ? undefined : s.flex1} - disabled={!enabled} - thumbTintColor={colors.blue3} - /> - - - - - ) -} - type Props = NativeStackScreenProps< CommonNavigatorParams, 'PreferencesFollowingFeed' @@ -137,51 +79,6 @@ export function PreferencesFollowingFeed({}: Props) { } /> - - - Reply Filters - - - - Enable this setting to only see replies between people you - follow. - - - - setFeedViewPref({ - hideRepliesByUnfollowed: !( - variables?.hideRepliesByUnfollowed ?? - preferences?.feedViewPrefs?.hideRepliesByUnfollowed - ), - }) - : undefined - } - style={[s.mb10]} - /> - - - Adjust the number of likes a reply must have to be shown in your - feed. - - - {preferences && ( - - )} - - Show Reposts