diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/feed-feedback.tsx | 6 | ||||
-rw-r--r-- | src/state/messages/convo/const.ts | 4 | ||||
-rw-r--r-- | src/state/messages/events/const.ts | 2 | ||||
-rw-r--r-- | src/state/persisted/schema.ts | 2 | ||||
-rw-r--r-- | src/state/preferences/index.tsx | 5 | ||||
-rw-r--r-- | src/state/preferences/opt-out-of-utm.tsx | 42 | ||||
-rw-r--r-- | src/state/queries/email-verification-required.ts | 25 | ||||
-rw-r--r-- | src/state/queries/messages/conversation.ts | 39 | ||||
-rw-r--r-- | src/state/queries/messages/list-converations.tsx | 16 | ||||
-rw-r--r-- | src/state/queries/post-feed.ts | 2 | ||||
-rw-r--r-- | src/state/queries/post-thread.ts | 32 | ||||
-rw-r--r-- | src/state/queries/post.ts | 24 | ||||
-rw-r--r-- | src/state/queries/preferences/const.ts | 2 | ||||
-rw-r--r-- | src/state/queries/preferences/types.ts | 2 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 14 | ||||
-rw-r--r-- | src/state/shell/minimal-mode.tsx | 16 |
16 files changed, 187 insertions, 46 deletions
diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 41579edc1..eaf6529f3 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -234,21 +234,21 @@ function flushToStatsig(stats: AggregatedStats | null) { } if (stats.clickthroughCount > 0) { - logEvent('discover:clickthrough:sampled', { + logEvent('discover:clickthrough', { count: stats.clickthroughCount, }) stats.clickthroughCount = 0 } if (stats.engagedCount > 0) { - logEvent('discover:engaged:sampled', { + logEvent('discover:engaged', { count: stats.engagedCount, }) stats.engagedCount = 0 } if (stats.seenCount > 0) { - logEvent('discover:seen:sampled', { + logEvent('discover:seen', { count: stats.seenCount, }) stats.seenCount = 0 diff --git a/src/state/messages/convo/const.ts b/src/state/messages/convo/const.ts index 5491d066e..ee0868401 100644 --- a/src/state/messages/convo/const.ts +++ b/src/state/messages/convo/const.ts @@ -1,5 +1,5 @@ -export const ACTIVE_POLL_INTERVAL = 3e3 -export const MESSAGE_SCREEN_POLL_INTERVAL = 10e3 +export const ACTIVE_POLL_INTERVAL = 4e3 +export const MESSAGE_SCREEN_POLL_INTERVAL = 30e3 export const BACKGROUND_POLL_INTERVAL = 60e3 export const INACTIVE_TIMEOUT = 60e3 * 5 diff --git a/src/state/messages/events/const.ts b/src/state/messages/events/const.ts index bfd7ce5fb..59af71360 100644 --- a/src/state/messages/events/const.ts +++ b/src/state/messages/events/const.ts @@ -1,2 +1,2 @@ -export const DEFAULT_POLL_INTERVAL = 60e3 * 5 +export const DEFAULT_POLL_INTERVAL = 60e3 export const BACKGROUND_POLL_INTERVAL = 60e3 * 5 diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 804017949..85a6bf8e2 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -124,6 +124,7 @@ const schema = z.object({ subtitlesEnabled: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), + optOutOfUtm: z.boolean().optional(), }) export type Schema = z.infer<typeof schema> @@ -169,6 +170,7 @@ export const defaults: Schema = { kawaii: false, hasCheckedForStarterPack: false, subtitlesEnabled: true, + optOutOfUtm: false, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index c7eaf2726..43a08926e 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' import {Provider as LargeAltBadgeProvider} from './large-alt-badge' +import {Provider as OutOutOfUtmProvider} from './opt-out-of-utm' import {Provider as SubtitlesProvider} from './subtitles' import {Provider as UsedStarterPacksProvider} from './used-starter-packs' @@ -39,7 +40,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { <AutoplayProvider> <UsedStarterPacksProvider> <SubtitlesProvider> - <KawaiiProvider>{children}</KawaiiProvider> + <OutOutOfUtmProvider> + <KawaiiProvider>{children}</KawaiiProvider> + </OutOutOfUtmProvider> </SubtitlesProvider> </UsedStarterPacksProvider> </AutoplayProvider> diff --git a/src/state/preferences/opt-out-of-utm.tsx b/src/state/preferences/opt-out-of-utm.tsx new file mode 100644 index 000000000..40144c8db --- /dev/null +++ b/src/state/preferences/opt-out-of-utm.tsx @@ -0,0 +1,42 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = boolean +type SetContext = (v: boolean) => void + +const stateContext = React.createContext<StateContext>( + Boolean(persisted.defaults.optOutOfUtm), +) +const setContext = React.createContext<SetContext>((_: boolean) => {}) + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = React.useState( + Boolean(persisted.get('optOutOfUtm')), + ) + + const setStateWrapped = React.useCallback( + (optOutOfUtm: persisted.Schema['optOutOfUtm']) => { + setState(Boolean(optOutOfUtm)) + persisted.write('optOutOfUtm', optOutOfUtm) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('optOutOfUtm', nextOptOutOfUtm => { + setState(Boolean(nextOptOutOfUtm)) + }) + }, [setStateWrapped]) + + return ( + <stateContext.Provider value={state}> + <setContext.Provider value={setStateWrapped}> + {children} + </setContext.Provider> + </stateContext.Provider> + ) +} + +export const useOptOutOfUtm = () => React.useContext(stateContext) +export const useSetOptOutOfUtm = () => React.useContext(setContext) diff --git a/src/state/queries/email-verification-required.ts b/src/state/queries/email-verification-required.ts new file mode 100644 index 000000000..94ff5cbc6 --- /dev/null +++ b/src/state/queries/email-verification-required.ts @@ -0,0 +1,25 @@ +import {useQuery} from '@tanstack/react-query' + +interface ServiceConfig { + checkEmailConfirmed: boolean +} + +export function useServiceConfigQuery() { + return useQuery({ + queryKey: ['service-config'], + queryFn: async () => { + const res = await fetch( + 'https://api.bsky.app/xrpc/app.bsky.unspecced.getConfig', + ) + if (!res.ok) { + return { + checkEmailConfirmed: false, + } + } + + const json = await res.json() + return json as ServiceConfig + }, + staleTime: 5 * 60 * 1000, + }) +} diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts index fa8a883d0..db96d21a9 100644 --- a/src/state/queries/messages/conversation.ts +++ b/src/state/queries/messages/conversation.ts @@ -5,7 +5,11 @@ import {STALE} from '#/state/queries' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useOnMarkAsRead} from '#/state/queries/messages/list-converations' import {useAgent} from '#/state/session' -import {RQKEY as LIST_CONVOS_KEY} from './list-converations' +import { + ConvoListQueryData, + getConvoFromQueryData, + RQKEY as LIST_CONVOS_KEY, +} from './list-converations' const RQKEY_ROOT = 'convo' export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId] @@ -57,8 +61,37 @@ export function useMarkAsReadMutation() { if (!convoId) throw new Error('No convoId provided') optimisticUpdate(convoId) }, - onSettled() { - queryClient.invalidateQueries({queryKey: LIST_CONVOS_KEY}) + onSuccess(_, {convoId}) { + if (!convoId) return + + queryClient.setQueryData(LIST_CONVOS_KEY, (old: ConvoListQueryData) => { + if (!old) return old + + const existingConvo = getConvoFromQueryData(convoId, old) + + if (existingConvo) { + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.map(convo => { + if (convo.id === convoId) { + return { + ...convo, + unreadCount: 0, + } + } + return convo + }), + } + }), + } + } else { + // If we somehow marked a convo as read that doesn't exist in the + // list, then we don't need to do anything. + } + }) }, }) } diff --git a/src/state/queries/messages/list-converations.tsx b/src/state/queries/messages/list-converations.tsx index eeab246ab..ae379f962 100644 --- a/src/state/queries/messages/list-converations.tsx +++ b/src/state/queries/messages/list-converations.tsx @@ -39,7 +39,7 @@ export function useListConvosQuery({ queryKey: RQKEY, queryFn: async ({pageParam}) => { const {data} = await agent.api.chat.bsky.convo.listConvos( - {cursor: pageParam}, + {cursor: pageParam, limit: 20}, {headers: DM_SERVICE_HEADERS}, ) @@ -47,9 +47,6 @@ export function useListConvosQuery({ }, initialPageParam: undefined as RQPageParam, getNextPageParam: lastPage => lastPage.cursor, - // refetch every 60 seconds since we can't get *all* info from the logs - // i.e. reading chats on another device won't update the unread count - refetchInterval: 60_000, }) } @@ -180,6 +177,11 @@ export function ListConvosProviderInner({ }), } } else { + /** + * We received a message from an conversation old enough that + * it doesn't exist in the query cache, meaning we need to + * refetch and bump the old convo to the top. + */ debouncedRefetch() } }) @@ -245,12 +247,12 @@ export function useUnreadMessageCount() { return useMemo(() => { return { count, - numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined, + numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined, } }, [count]) } -type ConvoListQueryData = { +export type ConvoListQueryData = { pageParams: Array<string | undefined> pages: Array<ChatBskyConvoListConvos.OutputSchema> } @@ -301,7 +303,7 @@ function optimisticDelete(chatId: string, old: ConvoListQueryData) { } } -function getConvoFromQueryData(chatId: string, old: ConvoListQueryData) { +export function getConvoFromQueryData(chatId: string, old: ConvoListQueryData) { for (const page of old.pages) { for (const convo of page.convos) { if (convo.id === chatId) { diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index b2e9dcd4c..016d8893b 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -602,7 +602,7 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { } if (!somePostsPassModeration) { - throw new Error(KnownError.FeedNSFPublic) + throw new Error(KnownError.FeedSignedInOnly) } } diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index 103a1d03b..4784a9d75 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -216,6 +216,17 @@ export function sortThread( } } + const aPin = Boolean(a.record.text.trim() === '📌') + const bPin = Boolean(b.record.text.trim() === '📌') + if (aPin !== bPin) { + if (aPin) { + return 1 + } + if (bPin) { + return -1 + } + } + if (opts.prioritizeFollowedUsers) { const af = a.post.author.viewer?.following const bf = b.post.author.viewer?.following @@ -226,7 +237,11 @@ export function sortThread( } } - if (opts.sort === 'oldest') { + if (opts.sort === 'hotness') { + const aHotness = getHotness(a.post) + const bHotness = getHotness(b.post) + return bHotness - aHotness + } else if (opts.sort === 'oldest') { return a.post.indexedAt.localeCompare(b.post.indexedAt) } else if (opts.sort === 'newest') { return b.post.indexedAt.localeCompare(a.post.indexedAt) @@ -258,6 +273,21 @@ export function sortThread( // internal methods // = +// Inspired by https://join-lemmy.org/docs/contributors/07-ranking-algo.html +// We want to give recent comments a real chance (and not bury them deep below the fold) +// while also surfacing well-liked comments from the past. In the future, we can explore +// something more sophisticated, but we don't have much data on the client right now. +function getHotness(post: AppBskyFeedDefs.PostView) { + const hoursAgo = + (new Date().getTime() - new Date(post.indexedAt).getTime()) / + (1000 * 60 * 60) + const likeCount = post.likeCount ?? 0 + const likeOrder = Math.log(3 + likeCount) + const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount)) + const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent) + return likeOrder / timePenalty +} + function responseToThreadNodes( node: ThreadViewNode, depth = 0, diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 7023580bb..7052590ca 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -98,8 +98,8 @@ export function useGetPosts() { export function usePostLikeMutationQueue( post: Shadow<AppBskyFeedDefs.PostView>, - logContext: LogEvents['post:like:sampled']['logContext'] & - LogEvents['post:unlike:sampled']['logContext'], + logContext: LogEvents['post:like']['logContext'] & + LogEvents['post:unlike']['logContext'], ) { const queryClient = useQueryClient() const postUri = post.uri @@ -157,7 +157,7 @@ export function usePostLikeMutationQueue( } function usePostLikeMutation( - logContext: LogEvents['post:like:sampled']['logContext'], + logContext: LogEvents['post:like']['logContext'], post: Shadow<AppBskyFeedDefs.PostView>, ) { const {currentAccount} = useSession() @@ -174,7 +174,7 @@ function usePostLikeMutation( if (currentAccount) { ownProfile = findProfileQueryData(queryClient, currentAccount.did) } - logEvent('post:like:sampled', { + logEvent('post:like', { logContext, doesPosterFollowLiker: postAuthor.viewer ? Boolean(postAuthor.viewer.followedBy) @@ -196,12 +196,12 @@ function usePostLikeMutation( } function usePostUnlikeMutation( - logContext: LogEvents['post:unlike:sampled']['logContext'], + logContext: LogEvents['post:unlike']['logContext'], ) { const agent = useAgent() return useMutation<void, Error, {postUri: string; likeUri: string}>({ mutationFn: ({likeUri}) => { - logEvent('post:unlike:sampled', {logContext}) + logEvent('post:unlike', {logContext}) return agent.deleteLike(likeUri) }, }) @@ -209,8 +209,8 @@ function usePostUnlikeMutation( export function usePostRepostMutationQueue( post: Shadow<AppBskyFeedDefs.PostView>, - logContext: LogEvents['post:repost:sampled']['logContext'] & - LogEvents['post:unrepost:sampled']['logContext'], + logContext: LogEvents['post:repost']['logContext'] & + LogEvents['post:unrepost']['logContext'], ) { const queryClient = useQueryClient() const postUri = post.uri @@ -266,7 +266,7 @@ export function usePostRepostMutationQueue( } function usePostRepostMutation( - logContext: LogEvents['post:repost:sampled']['logContext'], + logContext: LogEvents['post:repost']['logContext'], ) { const agent = useAgent() return useMutation< @@ -275,19 +275,19 @@ function usePostRepostMutation( {uri: string; cid: string} // the post's uri and cid >({ mutationFn: post => { - logEvent('post:repost:sampled', {logContext}) + logEvent('post:repost', {logContext}) return agent.repost(post.uri, post.cid) }, }) } function usePostUnrepostMutation( - logContext: LogEvents['post:unrepost:sampled']['logContext'], + logContext: LogEvents['post:unrepost']['logContext'], ) { const agent = useAgent() return useMutation<void, Error, {postUri: string; repostUri: string}>({ mutationFn: ({repostUri}) => { - logEvent('post:unrepost:sampled', {logContext}) + logEvent('post:unrepost', {logContext}) return agent.deleteRepost(repostUri) }, }) diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index e07f40ec5..549f7ce29 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -15,7 +15,7 @@ export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs } export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = { - sort: 'newest', + sort: 'hotness', prioritizeFollowedUsers: true, lab_treeViewEnabled: false, } diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts index 928bb90da..8f523fcf2 100644 --- a/src/state/queries/preferences/types.ts +++ b/src/state/queries/preferences/types.ts @@ -22,6 +22,6 @@ export type ThreadViewPreferences = Pick< BskyThreadViewPreference, 'prioritizeFollowedUsers' > & { - sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string + sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string lab_treeViewEnabled?: boolean } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 3059d9efe..63c405788 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -221,8 +221,8 @@ export function useProfileUpdateMutation() { export function useProfileFollowMutationQueue( profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, - logContext: LogEvents['profile:follow:sampled']['logContext'] & - LogEvents['profile:follow:sampled']['logContext'], + logContext: LogEvents['profile:follow']['logContext'] & + LogEvents['profile:follow']['logContext'], ) { const agent = useAgent() const queryClient = useQueryClient() @@ -293,7 +293,7 @@ export function useProfileFollowMutationQueue( } function useProfileFollowMutation( - logContext: LogEvents['profile:follow:sampled']['logContext'], + logContext: LogEvents['profile:follow']['logContext'], profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, ) { const {currentAccount} = useSession() @@ -308,7 +308,7 @@ function useProfileFollowMutation( ownProfile = findProfileQueryData(queryClient, currentAccount.did) } captureAction(ProgressGuideAction.Follow) - logEvent('profile:follow:sampled', { + logEvent('profile:follow', { logContext, didBecomeMutual: profile.viewer ? Boolean(profile.viewer.followedBy) @@ -322,12 +322,12 @@ function useProfileFollowMutation( } function useProfileUnfollowMutation( - logContext: LogEvents['profile:unfollow:sampled']['logContext'], + logContext: LogEvents['profile:unfollow']['logContext'], ) { const agent = useAgent() return useMutation<void, Error, {did: string; followUri: string}>({ mutationFn: async ({followUri}) => { - logEvent('profile:unfollow:sampled', {logContext}) + logEvent('profile:unfollow', {logContext}) return await agent.deleteFollow(followUri) }, }) @@ -409,7 +409,7 @@ function useProfileUnmuteMutation() { } export function useProfileBlockMutationQueue( - profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, + profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, ) { const queryClient = useQueryClient() const did = profile.did diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx index 3f1cebdf0..00547ee3e 100644 --- a/src/state/shell/minimal-mode.tsx +++ b/src/state/shell/minimal-mode.tsx @@ -44,13 +44,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) { 'worklet' // Cancel any existing animation cancelAnimation(headerMode) - headerMode.value = withSpring(v ? 1 : 0, { - overshootClamping: true, - }) + headerMode.set(() => + withSpring(v ? 1 : 0, { + overshootClamping: true, + }), + ) cancelAnimation(footerMode) - footerMode.value = withSpring(v ? 1 : 0, { - overshootClamping: true, - }) + footerMode.set(() => + withSpring(v ? 1 : 0, { + overshootClamping: true, + }), + ) }, [headerMode, footerMode], ) |