personal fork of bluesky app
bsky.kelinci.net
5.5 kB
164 lines
1import { useMemo, useState } from 'react';
2
3import { Checkbox } from '@base-ui/react/checkbox';
4import { CheckboxGroup } from '@base-ui/react/checkbox-group';
5import { useQueryClient } from '@tanstack/react-query';
6
7import { useDebouncedCallback } from '#/lib/hooks/use-debounced-callback';
8import { useTitle } from '#/lib/hooks/useTitle';
9import { interests as allInterests, useInterestsDisplayNames } from '#/lib/interests';
10
11import { preferencesQueryKey, usePreferencesQuery } from '#/state/queries/preferences';
12import { setInterestsPref } from '#/state/queries/preferences/agent';
13import type { UsePreferencesQueryResponse } from '#/state/queries/preferences/types';
14import { createGetSuggestedFeedsQueryKey } from '#/state/queries/trending/useGetSuggestedFeedsQuery';
15import { createGetSuggestedUsersForDiscoverQueryKey } from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery';
16import { createGetSuggestedUsersForExploreQueryKey } from '#/state/queries/trending/useGetSuggestedUsersForExploreQuery';
17import { createGetSuggestedUsersForSeeMoreQueryKey } from '#/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery';
18import { createSuggestedStarterPacksQueryKey } from '#/state/queries/useSuggestedStarterPacksQuery';
19import { getClients } from '#/state/session';
20
21import { Spinner } from '#/components/Spinner';
22import { Text } from '#/components/Text';
23import * as Toast from '#/components/Toast';
24import { Admonition } from '#/components/web/Admonition';
25import * as Layout from '#/components/web/Layout';
26
27import { m } from '#/paraglide/messages';
28
29import * as styles from './InterestsSettings.css';
30
31export function InterestsSettingsScreen() {
32 useTitle(m['common.interest.yourInterests']());
33 const { data: preferences } = usePreferencesQuery();
34 const [isSaving, setIsSaving] = useState(false);
35
36 return (
37 <Layout.Screen>
38 <Layout.Header.Outer>
39 <Layout.Header.BackButton />
40 <Layout.Header.Content>
41 <Layout.Header.TitleText>{m['common.interest.yourInterests']()}</Layout.Header.TitleText>
42 </Layout.Header.Content>
43 {isSaving && (
44 <Layout.Header.Slot>
45 <Spinner color="default" label={m['common.status.saving']()} size="sm" />
46 </Layout.Header.Slot>
47 )}
48 </Layout.Header.Outer>
49 <Layout.Content>
50 <div className={styles.body}>
51 <Text color="textContrastMedium" size="md_sub">
52 {m['screens.settings.interests.helpHint']()}
53 </Text>
54
55 {preferences ? (
56 <Inner preferences={preferences} setIsSaving={setIsSaving} />
57 ) : (
58 <div className={styles.loaderWrap}>
59 <Spinner color="default" label={m['common.status.loading']()} size="2xl" />
60 </div>
61 )}
62 </div>
63 </Layout.Content>
64 </Layout.Screen>
65 );
66}
67
68function Inner({
69 preferences,
70 setIsSaving,
71}: {
72 preferences: UsePreferencesQueryResponse;
73 setIsSaving: (isSaving: boolean) => void;
74}) {
75 const { pds } = getClients();
76 const qc = useQueryClient();
77 const interestsDisplayNames = useInterestsDisplayNames();
78 const preselectedInterests = useMemo(() => preferences.interests.tags || [], [preferences.interests.tags]);
79 const [interests, setInterests] = useState<string[]>(preselectedInterests);
80
81 // persist the edit even if the user leaves before the window closes
82 const saveInterests = useDebouncedCallback(
83 async (nextInterests: string[]) => {
84 const noEdits =
85 nextInterests.length === preselectedInterests.length &&
86 preselectedInterests.every((pre) => {
87 return nextInterests.find((int) => int === pre);
88 });
89
90 if (noEdits) {
91 return;
92 }
93
94 setIsSaving(true);
95
96 try {
97 await setInterestsPref(pds!, { tags: nextInterests });
98 qc.setQueriesData({ queryKey: preferencesQueryKey }, (old?: UsePreferencesQueryResponse) => {
99 if (!old) {
100 return old;
101 }
102 old.interests.tags = nextInterests;
103 return old;
104 });
105 await Promise.all([
106 qc.resetQueries({ queryKey: createSuggestedStarterPacksQueryKey() }),
107 qc.resetQueries({ queryKey: createGetSuggestedFeedsQueryKey() }),
108 qc.resetQueries({
109 queryKey: createGetSuggestedUsersForDiscoverQueryKey({}),
110 }),
111 qc.resetQueries({
112 queryKey: createGetSuggestedUsersForExploreQueryKey({}),
113 }),
114 qc.resetQueries({
115 queryKey: createGetSuggestedUsersForSeeMoreQueryKey({}),
116 }),
117 ]);
118
119 Toast.show(m['screens.settings.interests.updatedToast']());
120 } catch {
121 Toast.show(m['screens.settings.interests.saveError'](), {
122 type: 'error',
123 });
124 } finally {
125 setIsSaving(false);
126 }
127 },
128 1500,
129 { onUnmount: 'flush' },
130 );
131
132 const onChangeInterests = (nextInterests: string[]) => {
133 setInterests(nextInterests);
134 saveInterests(nextInterests);
135 };
136
137 return (
138 <>
139 {interests.length === 0 && (
140 <Admonition type="tip">{m['screens.settings.interests.recommendTwo']()}</Admonition>
141 )}
142 <CheckboxGroup
143 aria-label={m['screens.settings.interests.selectPrompt']()}
144 className={styles.chipWrap}
145 onValueChange={(value) => onChangeInterests(value)}
146 value={interests}
147 >
148 {allInterests.map((interest) => {
149 const name = interestsDisplayNames[interest];
150 if (!name) {
151 return null;
152 }
153 return (
154 <Checkbox.Root aria-label={name} className={styles.chip} key={interest} name={interest}>
155 <Text className={styles.chipText} selectable={false} size="md_sub" weight="semiBold">
156 {name}
157 </Text>
158 </Checkbox.Root>
159 );
160 })}
161 </CheckboxGroup>
162 </>
163 );
164}