feat: allow hide private subscription in view timeline (#3773)

This commit is contained in:
Stephen Zhou 2025-05-22 17:09:53 +08:00 committed by GitHub
parent 2ba2948693
commit f85f2f5fbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 181 additions and 31 deletions

View File

@ -12,7 +12,10 @@ export type DefinedQuery<TQueryKey extends QueryKey, TData> = Readonly<{
cancel: (key?: (key: TQueryKey) => QueryKey) => Promise<void>
remove: (key?: (key: TQueryKey) => QueryKey) => Promise<void>
invalidate: (key?: (key: TQueryKey) => QueryKey) => Promise<void>
invalidate: (options?: {
keyExtractor?: (key: TQueryKey) => QueryKey
exact?: boolean
}) => Promise<void>
invalidateRoot: () => void
refetch: () => Promise<TData | undefined>
@ -107,12 +110,14 @@ export function defineQuery<
const queryKey = typeof keyExtactor === "function" ? keyExtactor(key) : key
queryClient.removeQueries({ queryKey })
},
invalidate: async (keyExtactor) => {
const queryKey = typeof keyExtactor === "function" ? keyExtactor(key) : key
invalidate: async (args) => {
const { keyExtractor, exact } = args || {}
const queryKey = typeof keyExtractor === "function" ? keyExtractor(key) : key
await queryClient.invalidateQueries({
queryKey,
refetchType: "all",
exact,
})
options?.onInvalidate?.()
},

View File

@ -25,6 +25,7 @@ import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"
import { getGeneralSettings } from "~/atoms/settings/general"
import { Autocomplete } from "~/components/ui/auto-completion"
import { useCurrentModal, useIsInModal } from "~/components/ui/modal/stacked/hooks"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
@ -32,6 +33,7 @@ import { useAuthQuery, useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { tipcClient } from "~/lib/client"
import { toastFetchError } from "~/lib/error-parser"
import { entries as entriesQuery } from "~/queries/entries"
import { feed as feedQuery, useFeedQuery } from "~/queries/feed"
import { subscription as subscriptionQuery } from "~/queries/subscriptions"
import { useFeedByIdOrUrl } from "~/store/feed"
@ -245,6 +247,16 @@ const FeedInnerForm = ({
})
},
onSuccess: (_, variables) => {
if (getGeneralSettings().hidePrivateSubscriptionsInTimeline) {
entriesQuery
.entries({
feedId: "all",
view: Number(variables.view),
excludePrivate: true,
})
.invalidate({ exact: true })
}
if (isSubscribed && variables.view !== `${subscription?.view}`) {
feedUnreadActions.fetchUnreadByView(subscription?.view)
} else {

View File

@ -24,6 +24,7 @@ import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"
import { getGeneralSettings } from "~/atoms/settings/general"
import { useCurrentModal } from "~/components/ui/modal/stacked/hooks"
import { useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
@ -31,6 +32,7 @@ import { tipcClient } from "~/lib/client"
import { getFetchErrorMessage, toastFetchError } from "~/lib/error-parser"
import { getNewIssueUrl } from "~/lib/issues"
import { FollowSummary } from "~/modules/feed/feed-summary"
import { entries as entriesQuery } from "~/queries/entries"
import { lists as listsQuery, useList } from "~/queries/lists"
import { subscription as subscriptionQuery } from "~/queries/subscriptions"
import { useListById } from "~/store/list"
@ -219,6 +221,16 @@ const ListInnerForm = ({
})
},
onSuccess: (_, variables) => {
if (getGeneralSettings().hidePrivateSubscriptionsInTimeline) {
entriesQuery
.entries({
feedId: "all",
view: Number(variables.view),
excludePrivate: true,
})
.invalidate({ exact: true })
}
if (isSubscribed && variables.view !== `${subscription?.view}`) {
feedUnreadActions.fetchUnreadByView(subscription?.view)
} else {

View File

@ -51,6 +51,9 @@ const useRemoteEntries = (): UseEntriesReturn => {
const isPreview = useIsPreviewFeed()
const unreadOnly = useGeneralSettingKey("unreadOnly")
const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(
"hidePrivateSubscriptionsInTimeline",
)
const folderIds = useFolderFeedsByFeedId({
feedId,
@ -64,6 +67,7 @@ const useRemoteEntries = (): UseEntriesReturn => {
listId,
view,
...(unreadOnly === true && !isPreview && { read: false }),
...(hidePrivateSubscriptionsInTimeline === true && { excludePrivate: true }),
}
if (feedId && listId && isBizId(feedId)) {
@ -71,7 +75,16 @@ const useRemoteEntries = (): UseEntriesReturn => {
}
return params
}, [feedId, folderIds, inboxId, listId, unreadOnly, view, isPreview])
}, [
feedId,
folderIds,
inboxId,
listId,
unreadOnly,
isPreview,
view,
hidePrivateSubscriptionsInTimeline,
])
const query = useEntries(entriesOptions)
const [fetchedTime, setFetchedTime] = useState<number>()
@ -134,6 +147,9 @@ const useLocalEntries = (): UseEntriesReturn => {
const { feedId, view, inboxId, listId, isAllFeeds } = useRouteParams()
const unreadOnly = useGeneralSettingKey("unreadOnly")
const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(
"hidePrivateSubscriptionsInTimeline",
)
const folderIds = useFolderFeedsByFeedId({
feedId,
@ -145,6 +161,7 @@ const useLocalEntries = (): UseEntriesReturn => {
{
unread: unreadOnly,
view,
excludePrivate: hidePrivateSubscriptionsInTimeline,
},
)

View File

@ -1,3 +1,4 @@
import { getGeneralSettings } from "~/atoms/settings/general"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { getFolderFeedsByFeedId, subscriptionActions } from "~/store/subscription"
@ -17,7 +18,12 @@ export const markAllByRoute = async (filter?: MarkAllFilter) => {
if (!routerParams) return
if (typeof routerParams.feedId === "number" || routerParams.isAllFeeds) {
subscriptionActions.markReadByView(view, filter)
const { hidePrivateSubscriptionsInTimeline } = getGeneralSettings()
subscriptionActions.markReadByView({
view,
filter,
excludePrivate: hidePrivateSubscriptionsInTimeline,
})
} else if (inboxId) {
subscriptionActions.markReadByFeedIds({
inboxId,

View File

@ -103,11 +103,14 @@ export const SettingGeneral = () => {
label: t("general.auto_group.label"),
description: t("general.auto_group.description"),
}),
defineSettingItem("hideAllReadSubscriptions", {
label: t("general.hide_all_read_subscriptions.label"),
description: t("general.hide_all_read_subscriptions.description"),
}),
defineSettingItem("hidePrivateSubscriptionsInTimeline", {
label: t("general.hide_private_subscriptions_in_timeline.label"),
description: t("general.hide_private_subscriptions_in_timeline.description"),
}),
{
type: "title",

View File

@ -13,6 +13,7 @@ export const entries = {
listId,
view,
read,
excludePrivate,
limit,
}: {
feedId?: number | string
@ -20,10 +21,11 @@ export const entries = {
listId?: number | string
view?: number
read?: boolean
excludePrivate?: boolean
limit?: number
}) =>
defineQuery(
["entries", inboxId || listId || feedId, view, read, limit],
["entries", inboxId || listId || feedId, view, read, excludePrivate, limit],
async ({ pageParam }) =>
entryActions.fetchEntries({
feedId,
@ -31,6 +33,7 @@ export const entries = {
listId,
view,
read,
excludePrivate,
limit,
pageParam: pageParam as string,
}),
@ -130,30 +133,35 @@ export const useEntries = ({
listId,
view,
read,
excludePrivate,
}: {
feedId?: number | string
inboxId?: number | string
listId?: number | string
view?: number
read?: boolean
excludePrivate?: boolean
}) => {
const fetchUnread = read === false
const feedUnreadDirty = useFeedUnreadIsDirty((feedId as string) || "")
return useAuthInfiniteQuery(entries.entries({ feedId, inboxId, listId, view, read }), {
enabled: feedId !== undefined || inboxId !== undefined || listId !== undefined,
getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt,
initialPageParam: undefined,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
// DON'T refetch when the router is pop to previous page
refetchOnMount: fetchUnread && feedUnreadDirty && !history.isPop ? "always" : false,
return useAuthInfiniteQuery(
entries.entries({ feedId, inboxId, listId, view, read, excludePrivate }),
{
enabled: feedId !== undefined || inboxId !== undefined || listId !== undefined,
getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt,
initialPageParam: undefined,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
// DON'T refetch when the router is pop to previous page
refetchOnMount: fetchUnread && feedUnreadDirty && !history.isPop ? "always" : false,
staleTime:
// Force refetch unread entries when feed is dirty
// HACK: disable refetch when the router is pop to previous page
history.isPop ? Infinity : fetchUnread && feedUnreadDirty ? 0 : defaultStaleTime,
})
staleTime:
// Force refetch unread entries when feed is dirty
// HACK: disable refetch when the router is pop to previous page
history.isPop ? Infinity : fetchUnread && feedUnreadDirty ? 0 : defaultStaleTime,
},
)
}
export const useEntriesPreview = ({ id }: { id?: string }) =>

View File

@ -5,7 +5,7 @@ import { useCallback } from "react"
import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER } from "~/constants"
import { useListsFeedIds } from "../list"
import { useFeedIdByView } from "../subscription"
import { useFeedIdByView, useNonPrivateSubscriptionIds } from "../subscription"
import { getEntryIsInView, getFilteredFeedIds } from "./helper"
import { useEntryStore } from "./store"
import type { EntryFilter, FlatEntryModel } from "./types"
@ -82,12 +82,15 @@ export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) =>
export const useEntryIdsByView = (view: FeedViewType, filter?: EntryFilter) => {
const feedIds = useFeedIdByView(view)
const listFeedIds = useListsFeedIds(feedIds)
const nonPrivateFeedIds = useNonPrivateSubscriptionIds(feedIds)
const finalFeedIds = filter?.excludePrivate ? nonPrivateFeedIds : feedIds
const listFeedIds = useListsFeedIds(finalFeedIds)
return useEntryStore(
useCallback(
() => getFilteredFeedIds(Array.from(new Set([...feedIds, ...listFeedIds])), filter) || [],
[feedIds, listFeedIds, filter],
() =>
getFilteredFeedIds(Array.from(new Set([...finalFeedIds, ...listFeedIds])), filter) || [],
[finalFeedIds, listFeedIds, filter],
),
)
}

View File

@ -113,6 +113,7 @@ class EntryActions {
listId,
view,
read,
excludePrivate,
limit,
pageParam,
}: {
@ -121,6 +122,7 @@ class EntryActions {
listId?: number | string
view?: number
read?: boolean
excludePrivate?: boolean
limit?: number
pageParam?: string
}) {
@ -163,6 +165,7 @@ class EntryActions {
publishedAfter: pageParam,
read,
limit,
excludePrivate,
...params,
},
})

View File

@ -31,4 +31,5 @@ export interface EntryState {
export interface EntryFilter {
unread?: boolean
view?: FeedViewType
excludePrivate?: boolean
}

View File

@ -211,3 +211,9 @@ export const useInboxesGroupedData = (view: FeedViewType) => {
export const useIsSubscribed = (feedId: string) =>
useSubscriptionStore(useCallback((state) => isSubscribedSelector(feedId)(state), [feedId]))
export const useNonPrivateSubscriptionIds = (ids: string[]) => {
const subscriptions = useSubscriptionsByFeedIds(ids)
const nonPrivateSubscriptions = subscriptions.filter((s) => !!s).filter((s) => !s?.isPrivate)
return nonPrivateSubscriptions.map((s) => s.listId || s.feedId)
}

View File

@ -238,13 +238,22 @@ class SubscriptionActions {
})
}
async markReadByView(view: FeedViewType, filter?: MarkReadFilter) {
async markReadByView({
view,
excludePrivate,
filter,
}: {
view: FeedViewType
excludePrivate?: boolean
filter?: MarkReadFilter
}) {
const tx = createTransaction()
tx.execute(async () => {
await apiClient.reads.all.$post({
json: {
view,
excludePrivate,
...filter,
},
})
@ -257,6 +266,9 @@ class SubscriptionActions {
tx.optimistic(async () => {
const state = get()
for (const feedId in state.data) {
if (excludePrivate && state.data[feedId]?.isPrivate) {
return
}
if (state.data[feedId]!.view === view) {
if (state.data[feedId]?.listId) {
const listFeedIds = getListById(state.data[feedId].listId)?.feedIds

View File

@ -111,6 +111,9 @@ export const GeneralScreen: NavigationControllerView = () => {
const summary = useGeneralSettingKey("summary")
const autoGroup = useGeneralSettingKey("autoGroup")
const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions")
const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(
"hidePrivateSubscriptionsInTimeline",
)
const showUnreadOnLaunch = useGeneralSettingKey("unreadOnly")
// const groupByDate = useGeneralSettingKey("groupByDate")
const expandLongSocialMedia = useGeneralSettingKey("autoExpandLongSocialMedia")
@ -184,6 +187,19 @@ export const GeneralScreen: NavigationControllerView = () => {
}}
/>
</GroupedInsetListCell>
<GroupedInsetListCell
label={t("general.hide_private_subscriptions_in_timeline.label")}
description={t("general.hide_private_subscriptions_in_timeline.description")}
>
<Switch
size="sm"
value={hidePrivateSubscriptionsInTimeline}
onValueChange={(value) => {
setGeneralSetting("hidePrivateSubscriptionsInTimeline", value)
}}
/>
</GroupedInsetListCell>
</GroupedInsetListCard>
{/* Timeline */}

View File

@ -11,10 +11,28 @@ import type { EntryModel, FetchEntriesProps } from "./types"
export const usePrefetchEntries = (props: Omit<FetchEntriesProps, "pageParam" | "read"> | null) => {
const { feedId, inboxId, listId, view, limit, feedIdList } = props || {}
const unreadOnly = useGeneralSettingKey("unreadOnly")
const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(
"hidePrivateSubscriptionsInTimeline",
)
return useInfiniteQuery({
queryKey: ["entries", feedId, inboxId, listId, view, unreadOnly, limit, feedIdList],
queryKey: [
"entries",
feedId,
inboxId,
listId,
view,
unreadOnly,
limit,
feedIdList,
hidePrivateSubscriptionsInTimeline,
],
queryFn: ({ pageParam }) =>
entrySyncServices.fetchEntries({ ...props, pageParam, read: unreadOnly ? false : undefined }),
entrySyncServices.fetchEntries({
...props,
pageParam,
read: unreadOnly ? false : undefined,
excludePrivate: hidePrivateSubscriptionsInTimeline,
}),
getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt,
initialPageParam: undefined as undefined | string,
refetchOnWindowFocus: false,

View File

@ -2,6 +2,7 @@ import { FeedViewType } from "@follow/constants"
import { debounce } from "es-toolkit/compat"
import { fetch as expoFetch } from "expo/fetch"
import { getGeneralSettings } from "@/src/atoms/settings/general"
import { apiClient } from "@/src/lib/api-fetch"
import { getCookie } from "@/src/lib/auth"
import { honoMorph } from "@/src/morph/hono"
@ -68,16 +69,21 @@ class EntryActions {
sources?: string[] | null
}) {
if (!feedId) return
const subscription = getSubscription(feedId)
if (typeof subscription?.view === "number") {
const { hidePrivateSubscriptionsInTimeline } = getGeneralSettings()
const ignore = hidePrivateSubscriptionsInTimeline && subscription?.isPrivate
if (typeof subscription?.view === "number" && !ignore) {
draft.entryIdByView[subscription.view].add(entryId)
}
// lists
for (const s of sources ?? []) {
const subscription = getSubscription(s)
const ignore = hidePrivateSubscriptionsInTimeline && subscription?.isPrivate
if (typeof subscription?.view === "number") {
if (typeof subscription?.view === "number" && !ignore) {
draft.entryIdByView[subscription.view].add(entryId)
}
}
@ -363,8 +369,18 @@ class EntryActions {
class EntrySyncServices {
async fetchEntries(props: FetchEntriesProps) {
const { feedId, inboxId, listId, view, read, limit, pageParam, isCollection, feedIdList } =
props
const {
feedId,
inboxId,
listId,
view,
read,
limit,
pageParam,
isCollection,
feedIdList,
excludePrivate,
} = props
const params = getEntriesParams({
feedId,
inboxId,
@ -389,6 +405,7 @@ class EntrySyncServices {
read,
limit,
isCollection,
excludePrivate,
...params,
},
})

View File

@ -14,4 +14,5 @@ export type FetchEntriesProps = {
limit?: number
pageParam?: string
isCollection?: boolean
excludePrivate?: boolean
}

View File

@ -1,5 +1,6 @@
import type { FeedViewType } from "@follow/constants"
import { getGeneralSettings } from "@/src/atoms/settings/general"
import type { UnreadSchema } from "@/src/database/schemas/types"
import { apiClient } from "@/src/lib/api-fetch"
import { setBadgeCountAsyncWithPermission } from "@/src/lib/permission"
@ -68,9 +69,11 @@ class UnreadSyncService {
} | null
time?: PublishAtTimeRangeFilter
}) {
const { hidePrivateSubscriptionsInTimeline } = getGeneralSettings()
await apiClient.reads.all.$post({
json: {
view,
excludePrivate: hidePrivateSubscriptionsInTimeline,
...filter,
...time,
},

View File

@ -186,6 +186,8 @@
"general.group_by_date.label": "Group by date",
"general.hide_all_read_subscriptions.description": "Hide subscriptions without unread entries in the subscription list.",
"general.hide_all_read_subscriptions.label": "Hide read",
"general.hide_private_subscriptions_in_timeline.description": "Hide private subscriptions in the View Timeline.",
"general.hide_private_subscriptions_in_timeline.label": "Hide private",
"general.language": "Language",
"general.launch_at_login": "Launch at login",
"general.log_file.button": "Reveal",

View File

@ -185,6 +185,8 @@
"general.group_by_date.label": "按日期分组",
"general.hide_all_read_subscriptions.description": "在订阅列表中隐藏没有未读条目的订阅。",
"general.hide_all_read_subscriptions.label": "隐藏已读",
"general.hide_private_subscriptions_in_timeline.description": "在视图时间线中隐藏私密订阅。",
"general.hide_private_subscriptions_in_timeline.label": "隐藏私密",
"general.language": "语言",
"general.launch_at_login": "开机时启动",
"general.log_file.button": "显示",

View File

@ -19,6 +19,7 @@ export const defaultGeneralSettings: GeneralSettings = {
// subscription
autoGroup: true,
hideAllReadSubscriptions: false,
hidePrivateSubscriptionsInTimeline: false,
// view
unreadOnly: true,

View File

@ -20,6 +20,8 @@ export interface GeneralSettings {
// subscription
autoGroup: boolean
hideAllReadSubscriptions: boolean
hidePrivateSubscriptionsInTimeline: boolean
/**
* Top timeline for mobile
*/