An ATproto social media client -- with an independent Appview.
1import {useCallback, useMemo, useRef, useState} from 'react'
2import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
3import debounce from 'lodash.debounce'
4
5import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce'
6import {logger} from '#/logger'
7import {
8 usePreferencesQuery,
9 useSetThreadViewPreferencesMutation,
10} from '#/state/queries/preferences'
11import {type ThreadViewPreferences} from '#/state/queries/preferences/types'
12import {type Literal} from '#/types/utils'
13
14export type ThreadSortOption = Literal<
15 AppBskyUnspeccedGetPostThreadV2.QueryParams['sort'],
16 string
17>
18export type ThreadViewOption = 'linear' | 'tree'
19export type ThreadPreferences = {
20 isLoaded: boolean
21 isSaving: boolean
22 sort: ThreadSortOption
23 setSort: (sort: string) => void
24 view: ThreadViewOption
25 setView: (view: ThreadViewOption) => void
26 prioritizeFollowedUsers: boolean
27 setPrioritizeFollowedUsers: (prioritize: boolean) => void
28}
29
30export function useThreadPreferences({
31 save,
32}: {save?: boolean} = {}): ThreadPreferences {
33 const {data: preferences} = usePreferencesQuery()
34 const serverPrefs = preferences?.threadViewPrefs
35 const once = useCallOnce(OnceKey.PreferencesThread)
36
37 /*
38 * Create local state representations of server state
39 */
40 const [sort, setSort] = useState(normalizeSort(serverPrefs?.sort || 'top'))
41 const [view, setView] = useState(
42 normalizeView({
43 treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled,
44 }),
45 )
46 const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState(
47 !!serverPrefs?.prioritizeFollowedUsers,
48 )
49
50 /**
51 * If we get a server update, update local state
52 */
53 const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs)
54 const isLoaded = !!prevServerPrefs
55 if (serverPrefs && prevServerPrefs !== serverPrefs) {
56 setPrevServerPrefs(serverPrefs)
57
58 /*
59 * Update
60 */
61 setSort(normalizeSort(serverPrefs.sort))
62 setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers)
63 setView(
64 normalizeView({
65 treeViewEnabled: !!serverPrefs.lab_treeViewEnabled,
66 }),
67 )
68
69 once(() => {
70 logger.metric('thread:preferences:load', {
71 sort: serverPrefs.sort,
72 view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear',
73 prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers,
74 })
75 })
76 }
77
78 const userUpdatedPrefs = useRef(false)
79 const [isSaving, setIsSaving] = useState(false)
80 const {mutateAsync} = useSetThreadViewPreferencesMutation()
81 const savePrefs = useMemo(() => {
82 return debounce(async (prefs: ThreadViewPreferences) => {
83 try {
84 setIsSaving(true)
85 await mutateAsync(prefs)
86 logger.metric('thread:preferences:update', {
87 sort: prefs.sort,
88 view: prefs.lab_treeViewEnabled ? 'tree' : 'linear',
89 prioritizeFollowedUsers: prefs.prioritizeFollowedUsers,
90 })
91 } catch (e) {
92 logger.error('useThreadPreferences failed to save', {
93 safeMessage: e,
94 })
95 } finally {
96 setIsSaving(false)
97 }
98 }, 4e3)
99 }, [mutateAsync])
100
101 if (save && userUpdatedPrefs.current) {
102 savePrefs({
103 sort,
104 prioritizeFollowedUsers,
105 lab_treeViewEnabled: view === 'tree',
106 })
107 userUpdatedPrefs.current = false
108 }
109
110 const setSortWrapped = useCallback(
111 (next: string) => {
112 userUpdatedPrefs.current = true
113 setSort(normalizeSort(next))
114 },
115 [setSort],
116 )
117 const setViewWrapped = useCallback(
118 (next: ThreadViewOption) => {
119 userUpdatedPrefs.current = true
120 setView(next)
121 },
122 [setView],
123 )
124 const setPrioritizeFollowedUsersWrapped = useCallback(
125 (next: boolean) => {
126 userUpdatedPrefs.current = true
127 setPrioritizeFollowedUsers(next)
128 },
129 [setPrioritizeFollowedUsers],
130 )
131
132 return useMemo(
133 () => ({
134 isLoaded,
135 isSaving,
136 sort,
137 setSort: setSortWrapped,
138 view,
139 setView: setViewWrapped,
140 prioritizeFollowedUsers,
141 setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped,
142 }),
143 [
144 isLoaded,
145 isSaving,
146 sort,
147 setSortWrapped,
148 view,
149 setViewWrapped,
150 prioritizeFollowedUsers,
151 setPrioritizeFollowedUsersWrapped,
152 ],
153 )
154}
155
156/**
157 * Migrates user thread preferences from the old sort values to V2
158 */
159export function normalizeSort(sort: string): ThreadSortOption {
160 switch (sort) {
161 case 'oldest':
162 return 'oldest'
163 case 'newest':
164 return 'newest'
165 default:
166 return 'top'
167 }
168}
169
170/**
171 * Transforms existing treeViewEnabled preference into a ThreadViewOption
172 */
173export function normalizeView({
174 treeViewEnabled,
175}: {
176 treeViewEnabled: boolean
177}): ThreadViewOption {
178 return treeViewEnabled ? 'tree' : 'linear'
179}