@oxy.so/services 12.10.1 → 12.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/lib/commonjs/ui/components/accountSettings/AccountCategoriesSection.js +142 -0
  2. package/lib/commonjs/ui/components/accountSettings/AccountCategoriesSection.js.map +1 -0
  3. package/lib/commonjs/ui/components/accountSettings/AccountSettings.js +361 -0
  4. package/lib/commonjs/ui/components/accountSettings/AccountSettings.js.map +1 -0
  5. package/lib/commonjs/ui/hooks/useAvatarPicker.js +54 -48
  6. package/lib/commonjs/ui/hooks/useAvatarPicker.js.map +1 -1
  7. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +16 -237
  8. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  9. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js +23 -4
  10. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js.map +1 -1
  11. package/lib/commonjs/ui/screens/CreateAccountScreen.js +6 -52
  12. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  13. package/lib/commonjs/ui/screens/index.js +7 -0
  14. package/lib/commonjs/ui/screens/index.js.map +1 -1
  15. package/lib/module/ui/components/accountSettings/AccountCategoriesSection.js +134 -0
  16. package/lib/module/ui/components/accountSettings/AccountCategoriesSection.js.map +1 -0
  17. package/lib/module/ui/components/accountSettings/AccountSettings.js +355 -0
  18. package/lib/module/ui/components/accountSettings/AccountSettings.js.map +1 -0
  19. package/lib/module/ui/hooks/useAvatarPicker.js +53 -48
  20. package/lib/module/ui/hooks/useAvatarPicker.js.map +1 -1
  21. package/lib/module/ui/screens/AccountSettingsScreen.js +16 -235
  22. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  23. package/lib/module/ui/screens/ChangeAvatarScreen.js +23 -4
  24. package/lib/module/ui/screens/ChangeAvatarScreen.js.map +1 -1
  25. package/lib/module/ui/screens/CreateAccountScreen.js +7 -53
  26. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  27. package/lib/module/ui/screens/index.js +4 -0
  28. package/lib/module/ui/screens/index.js.map +1 -1
  29. package/lib/typescript/commonjs/ui/components/accountSettings/AccountCategoriesSection.d.ts +26 -0
  30. package/lib/typescript/commonjs/ui/components/accountSettings/AccountCategoriesSection.d.ts.map +1 -0
  31. package/lib/typescript/commonjs/ui/components/accountSettings/AccountSettings.d.ts +80 -0
  32. package/lib/typescript/commonjs/ui/components/accountSettings/AccountSettings.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts +22 -1
  34. package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts +4 -5
  36. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/screens/index.d.ts +1 -0
  40. package/lib/typescript/commonjs/ui/screens/index.d.ts.map +1 -1
  41. package/lib/typescript/module/ui/components/accountSettings/AccountCategoriesSection.d.ts +26 -0
  42. package/lib/typescript/module/ui/components/accountSettings/AccountCategoriesSection.d.ts.map +1 -0
  43. package/lib/typescript/module/ui/components/accountSettings/AccountSettings.d.ts +80 -0
  44. package/lib/typescript/module/ui/components/accountSettings/AccountSettings.d.ts.map +1 -0
  45. package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts +22 -1
  46. package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts.map +1 -1
  47. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts +4 -5
  48. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  50. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/screens/index.d.ts +1 -0
  52. package/lib/typescript/module/ui/screens/index.d.ts.map +1 -1
  53. package/package.json +4 -4
  54. package/src/ui/components/accountSettings/AccountCategoriesSection.tsx +180 -0
  55. package/src/ui/components/accountSettings/AccountSettings.tsx +421 -0
  56. package/src/ui/components/accountSettings/__tests__/AccountSettings.test.tsx +101 -0
  57. package/src/ui/hooks/useAvatarPicker.ts +65 -69
  58. package/src/ui/screens/AccountSettingsScreen.tsx +13 -277
  59. package/src/ui/screens/ChangeAvatarScreen.tsx +25 -6
  60. package/src/ui/screens/CreateAccountScreen.tsx +10 -61
  61. package/src/ui/screens/index.ts +8 -0
@@ -0,0 +1,421 @@
1
+ import type React from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { useCallback, useMemo, useState } from 'react';
4
+ import { View } from 'react-native';
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
6
+ import { Avatar } from '@oxy.so/bloom/avatar';
7
+ import { Button } from '@oxy.so/bloom/button';
8
+ import { GroupedSection, GroupedSectionItem } from '@oxy.so/bloom/grouped-section';
9
+ import { surfaces } from '@oxy.so/bloom/surfaces';
10
+ import { TextField, TextFieldInput } from '@oxy.so/bloom/text-field';
11
+ import { useTheme } from '@oxy.so/bloom/theme';
12
+ import { toast } from '@oxy.so/bloom/toast';
13
+ import { Text } from '@oxy.so/bloom/typography';
14
+ import {
15
+ accountNameInvalidKey,
16
+ accountNameInvalidMessage,
17
+ accountNameMaxLength,
18
+ getNormalizedUserHandle,
19
+ isValidAccountName,
20
+ kindAcceptsAccountCategories,
21
+ type AccountCategoryId,
22
+ type AccountNode,
23
+ type UpdateAccountInput,
24
+ type User,
25
+ } from '@oxy.so/core';
26
+ import { useOxy } from '../../context/oxyRuntimeContext';
27
+ import { useI18n } from '../../hooks/useI18n';
28
+ import { pickAvatarFile } from '../../hooks/useAvatarPicker';
29
+ import {
30
+ clearedFieldsFromAccountUpdate,
31
+ upsertCachedUser,
32
+ type ClearableUserField,
33
+ } from '../../hooks/queries/userCache';
34
+ import type { BaseScreenProps } from '../../types/navigation';
35
+ import { OrganizationSettings } from '../organizations/OrganizationSettings';
36
+ import { SettingsIcon } from '../SettingsIcon';
37
+ import { AccountCategoriesSection, accountCategoriesEqual } from './AccountCategoriesSection';
38
+
39
+ const BIO_MAX = 160;
40
+ const AVATAR_SIZE = 64;
41
+
42
+ const errorMessage = (error: unknown, fallback: string): string =>
43
+ error instanceof Error && error.message ? error.message : fallback;
44
+
45
+ /**
46
+ * How archiving the account behaves, for an app whose ending of an account
47
+ * means more than Oxy's archive.
48
+ */
49
+ export interface AccountSettingsArchiveOptions {
50
+ /** The danger-zone row's title, replacing "Archive account". */
51
+ title?: string;
52
+ /**
53
+ * The row's subtitle. With a `title` of its own the row shows only this
54
+ * (nothing when omitted), never Oxy's subtitle under the app's title.
55
+ */
56
+ description?: string;
57
+ /** The danger-zone section's footer. */
58
+ footer?: string;
59
+ /**
60
+ * Replaces the default confirmation. Resolve `false` to back out; a rejection
61
+ * is reported as an archive failure and nothing is archived.
62
+ */
63
+ confirm?: (node: AccountNode) => Promise<boolean>;
64
+ /**
65
+ * Runs after the confirmation and BEFORE Oxy archives the account — for an
66
+ * app's own data that has to go while the account still resolves. A
67
+ * rejection stops the archive and is reported as an archive failure.
68
+ */
69
+ beforeArchive?: (node: AccountNode) => Promise<void>;
70
+ /** After the archive, in place of closing the surface. */
71
+ onArchived?: (node: AccountNode) => void;
72
+ /** The success toast, replacing "Account archived". */
73
+ successMessage?: string;
74
+ }
75
+
76
+ export interface AccountSettingsProps {
77
+ accountId: string;
78
+ /**
79
+ * The app's own sections for this account, rendered after the account's
80
+ * profile, categories and members, and before the danger zone.
81
+ */
82
+ extraSections?: ReactNode;
83
+ /**
84
+ * After each accepted profile write — name, bio, picture, categories — with
85
+ * the account Oxy returned and the fields the write cleared. The SDK's user
86
+ * cache already holds the change; this is for an app's own copies of the
87
+ * identity (posts that embed it, for example).
88
+ */
89
+ onUpdated?: (account: User, change: { cleared: ClearableUserField[] }) => void;
90
+ archive?: AccountSettingsArchiveOptions;
91
+ /**
92
+ * The host surface's navigation, for the SDK routes this opens (Members).
93
+ * Omitted when the settings are embedded in an app's own page, and those
94
+ * routes open as a sheet.
95
+ */
96
+ navigate?: BaseScreenProps['navigate'];
97
+ /** Called after an archive when `archive.onArchived` is not given. */
98
+ onClose?: () => void;
99
+ }
100
+
101
+ /**
102
+ * The account-graph node {@link AccountSettings} edits — seeded from the
103
+ * session's account list, then read fresh. `node` is `null` until it resolves.
104
+ */
105
+ export function useAccountSettingsNode(accountId: string) {
106
+ const { oxyServices, canUsePrivateApi, user, accounts } = useOxy();
107
+ const query = useQuery({
108
+ queryKey: ['accounts', 'detail', accountId, user?.id, canUsePrivateApi],
109
+ initialData: canUsePrivateApi
110
+ ? accounts?.find((account) => account.accountId === accountId)
111
+ : undefined,
112
+ initialDataUpdatedAt: 0,
113
+ queryFn: () => oxyServices.accounts.get(accountId),
114
+ enabled: canUsePrivateApi && accountId.length > 0,
115
+ });
116
+ return { query, node: canUsePrivateApi && query.isSuccess ? query.data : null };
117
+ }
118
+
119
+ /**
120
+ * A non-personal account's settings: its picture, name, bio and categories,
121
+ * its members, and archiving it. The one editor for every member-operated
122
+ * account — the `AccountSettings` surface renders it, and an app embeds it in
123
+ * its own page (`@oxy.so/services/screens`) with its own sections and archive
124
+ * steps around it. Oxy decides what the caller may do from the membership it
125
+ * resolved, so every section is shown only to a member who holds its right.
126
+ */
127
+ export const AccountSettings: React.FC<AccountSettingsProps> = ({
128
+ accountId,
129
+ extraSections,
130
+ onUpdated,
131
+ archive,
132
+ navigate,
133
+ onClose,
134
+ }) => {
135
+ const { colors } = useTheme();
136
+ const { t } = useI18n();
137
+ const { oxyServices, user, accounts, switchToAccount, showBottomSheet } = useOxy();
138
+ const queryClient = useQueryClient();
139
+ const id = accountId;
140
+ const { query: accountQuery, node } = useAccountSettingsNode(id);
141
+ const account = node?.account;
142
+ const permissions = node?.callerMembership?.permissions ?? [];
143
+ const isImplicitOwner = node ? node.relationship !== 'member' : false;
144
+ const can = useCallback(
145
+ (permission: string): boolean => isImplicitOwner || permissions.includes(permission),
146
+ [isImplicitOwner, permissions],
147
+ );
148
+ const canUpdate = can('account:update');
149
+ const canViewMembers = can('members:read');
150
+ const canArchive = can('account:delete');
151
+ const hasCategories = kindAcceptsAccountCategories(node?.kind);
152
+
153
+ // Editable fields, seeded once from the loaded account and NOT re-synced: while
154
+ // the form is open it is the only writer, so a sync could only overwrite what
155
+ // is being typed. The stored values are read on every render for the dirty
156
+ // check, so a save settles the button without anything mirroring state.
157
+ const [seeded, setSeeded] = useState(false);
158
+ const [displayName, setDisplayName] = useState('');
159
+ const [displayNameError, setDisplayNameError] = useState('');
160
+ const [bio, setBio] = useState('');
161
+ // Verbatim, including an id this build cannot name: dropping it on load would
162
+ // delete it on the next save.
163
+ const [categories, setCategories] = useState<AccountCategoryId[]>([]);
164
+
165
+ const storedName = account?.name?.displayName ?? getNormalizedUserHandle(account) ?? '';
166
+ const storedCategories = account?.accountCategories ?? [];
167
+ if (node && !seeded) {
168
+ setDisplayName(storedName);
169
+ setBio(account?.bio ?? '');
170
+ setCategories([...storedCategories]);
171
+ setSeeded(true);
172
+ }
173
+
174
+ const updateMutation = useMutation({
175
+ mutationKey: ['accounts', 'update', id],
176
+ mutationFn: (input: UpdateAccountInput) => oxyServices.accounts.update(id, input),
177
+ onSuccess: (updatedNode, input) => {
178
+ const cleared = clearedFieldsFromAccountUpdate(input);
179
+ upsertCachedUser(queryClient, updatedNode.account, user?.id, {
180
+ cleared: cleared.length > 0 ? cleared : undefined,
181
+ });
182
+ onUpdated?.(updatedNode.account, { cleared });
183
+ queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
184
+ queryClient.invalidateQueries({ queryKey: ['accounts'] });
185
+ toast.success(t('accounts.settings.toasts.saved'));
186
+ },
187
+ onError: (error) => {
188
+ toast.error(errorMessage(error, t('accounts.settings.toasts.saveFailed')));
189
+ },
190
+ });
191
+
192
+ const archiveMutation = useMutation({
193
+ mutationKey: ['accounts', 'archive', id],
194
+ mutationFn: async (target: AccountNode) => {
195
+ await archive?.beforeArchive?.(target);
196
+ await oxyServices.accounts.archive(id);
197
+ return target;
198
+ },
199
+ onSuccess: async (target) => {
200
+ // If we archived the account we're currently signed in AS, switch back to
201
+ // the personal account so the app isn't left as an archived identity.
202
+ if (user?.id === id) {
203
+ const personal = accounts.find((entry) => entry.relationship === 'self');
204
+ if (personal) {
205
+ await switchToAccount(personal.accountId).catch(() => undefined);
206
+ }
207
+ }
208
+ queryClient.invalidateQueries({ queryKey: ['accounts'] });
209
+ toast.success(archive?.successMessage ?? t('accounts.settings.toasts.archived'));
210
+ if (archive?.onArchived) archive.onArchived(target);
211
+ else onClose?.();
212
+ },
213
+ onError: (error) => {
214
+ toast.error(errorMessage(error, t('accounts.settings.toasts.archiveFailed')));
215
+ },
216
+ });
217
+
218
+ const handleArchive = useCallback(async () => {
219
+ if (!node) return;
220
+ let confirmed: boolean;
221
+ try {
222
+ confirmed = archive?.confirm
223
+ ? await archive.confirm(node)
224
+ : await surfaces.confirm({
225
+ title: t('accounts.settings.archive.confirmTitle'),
226
+ description: t('accounts.settings.archive.confirmDescription'),
227
+ confirmLabel: t('accounts.settings.archive.title'),
228
+ cancelLabel: t('common.cancel'),
229
+ destructive: true,
230
+ });
231
+ } catch (error) {
232
+ toast.error(errorMessage(error, t('accounts.settings.toasts.archiveFailed')));
233
+ return;
234
+ }
235
+ if (confirmed) archiveMutation.mutate(node);
236
+ }, [archive, archiveMutation, node, t]);
237
+
238
+ const handleDisplayNameChange = useCallback(
239
+ (value: string) => {
240
+ setDisplayName(value);
241
+ const trimmed = value.trim();
242
+ if (!trimmed) {
243
+ setDisplayNameError('');
244
+ return;
245
+ }
246
+ const kind = node?.account.kind;
247
+ setDisplayNameError(
248
+ isValidAccountName(trimmed, kind)
249
+ ? ''
250
+ : t(`accounts.settings.displayName.${accountNameInvalidKey(kind)}`) ||
251
+ accountNameInvalidMessage(kind),
252
+ );
253
+ },
254
+ [node?.account.kind, t],
255
+ );
256
+
257
+ const trimmedName = displayName.trim();
258
+ const trimmedBio = bio.trim();
259
+ const categoriesChanged = hasCategories && !accountCategoriesEqual(categories, storedCategories);
260
+ const profileChanged =
261
+ trimmedName !== storedName.trim() ||
262
+ trimmedBio !== (account?.bio ?? '').trim() ||
263
+ categoriesChanged;
264
+ // An empty display name is refused rather than sent: at Oxy an empty
265
+ // `displayName` clears the explicit name, which a managed account (a title,
266
+ // not a given-and-family name) has nothing to fall back from.
267
+ const canSave = profileChanged && trimmedName.length > 0 && !displayNameError;
268
+
269
+ const handleSave = useCallback(() => {
270
+ if (!canSave) return;
271
+ updateMutation.mutate({
272
+ name: { displayName: trimmedName },
273
+ // `null` is Oxy's clear; an empty string would store one.
274
+ bio: trimmedBio ? trimmedBio : null,
275
+ // Whole and IN ORDER — element 0 is the primary, and `[]` is the clear.
276
+ ...(categoriesChanged ? { accountCategories: categories } : null),
277
+ });
278
+ }, [canSave, categories, categoriesChanged, trimmedBio, trimmedName, updateMutation]);
279
+
280
+ // The picture is written on its own, as the personal profile's is: the shared
281
+ // picker uploads the crop, and the account takes it (or `null`, its clear).
282
+ const handleChangePicture = useCallback(async () => {
283
+ try {
284
+ const avatar = await pickAvatarFile(oxyServices, {
285
+ avatar: account?.avatar,
286
+ displayName: storedName,
287
+ });
288
+ if (avatar !== undefined) updateMutation.mutate({ avatar });
289
+ } catch (error) {
290
+ toast.error(errorMessage(error, t('accounts.settings.toasts.saveFailed')));
291
+ }
292
+ }, [account?.avatar, oxyServices, storedName, t, updateMutation]);
293
+
294
+ const avatarUri = useMemo(
295
+ () => (account?.avatar ? oxyServices.assets.publicUrl(account.avatar, 'thumb') : undefined),
296
+ [account?.avatar, oxyServices],
297
+ );
298
+
299
+ const openMembers = useCallback(() => {
300
+ if (navigate) navigate('AccountMembers', { accountId: id });
301
+ else showBottomSheet?.({ screen: 'AccountMembers', props: { accountId: id } });
302
+ }, [id, navigate, showBottomSheet]);
303
+
304
+ if (!id) {
305
+ return (
306
+ <View className="items-center justify-center px-screen-margin py-space-40">
307
+ <Text className="text-body font-body text-text-secondary text-center">
308
+ {t('accounts.settings.errors.missingAccount')}
309
+ </Text>
310
+ </View>
311
+ );
312
+ }
313
+
314
+ if (accountQuery.isLoading || !node) {
315
+ return <View className="items-center py-space-32" />;
316
+ }
317
+
318
+ const busy = updateMutation.isPending;
319
+
320
+ return (
321
+ <View className="px-screen-margin pt-space-24 pb-space-32 gap-space-24">
322
+ {canUpdate ? (
323
+ <View className="gap-space-16 p-space-16 rounded-radius-20 bg-fill">
324
+ <View className="flex-row items-center gap-space-16">
325
+ <Avatar source={avatarUri} name={storedName} size={AVATAR_SIZE} />
326
+ <Button
327
+ appearance="subtle"
328
+ size="sm"
329
+ onPress={() => void handleChangePicture()}
330
+ disabled={busy}
331
+ >
332
+ {t('accounts.settings.picture.change')}
333
+ </Button>
334
+ </View>
335
+ <View className="gap-space-4">
336
+ <TextField isInvalid={Boolean(displayNameError)}>
337
+ <TextFieldInput
338
+ floatingLabel
339
+ label={t('accounts.settings.displayName.label')}
340
+ value={displayName}
341
+ onChangeText={handleDisplayNameChange}
342
+ isInvalid={Boolean(displayNameError)}
343
+ maxLength={accountNameMaxLength(node.account.kind)}
344
+ />
345
+ </TextField>
346
+ {displayNameError ? (
347
+ <Text className="text-caption font-caption text-negative px-space-4">
348
+ {displayNameError}
349
+ </Text>
350
+ ) : null}
351
+ </View>
352
+ <View className="gap-space-4">
353
+ <TextField>
354
+ <TextFieldInput
355
+ floatingLabel
356
+ label={t('accounts.settings.bio.label')}
357
+ value={bio}
358
+ onChangeText={setBio}
359
+ maxLength={BIO_MAX}
360
+ multiline
361
+ numberOfLines={3}
362
+ textAlignVertical="top"
363
+ />
364
+ </TextField>
365
+ <Text className="text-caption font-caption text-text-tertiary px-space-4 text-right">
366
+ {bio.length}/{BIO_MAX}
367
+ </Text>
368
+ </View>
369
+ {hasCategories ? (
370
+ <AccountCategoriesSection value={categories} onChange={setCategories} disabled={busy} />
371
+ ) : null}
372
+ <Button
373
+ appearance="solid"
374
+ tone="accent"
375
+ onPress={handleSave}
376
+ disabled={busy || !canSave}
377
+ loading={busy}
378
+ accessibilityLabel={t('accounts.settings.save')}
379
+ className="w-full"
380
+ >
381
+ {t('accounts.settings.save')}
382
+ </Button>
383
+ </View>
384
+ ) : null}
385
+
386
+ {node.account.kind === 'organization' ? (
387
+ <OrganizationSettings key={id} accountId={id} canUpdate={canUpdate} />
388
+ ) : null}
389
+
390
+ {canViewMembers ? (
391
+ <GroupedSection title={t('accounts.settings.sections.access')}>
392
+ <GroupedSectionItem
393
+ icon={<SettingsIcon name="account-multiple" color={colors.info} />}
394
+ title={t('accounts.members.title')}
395
+ description={t('accounts.settings.members.subtitle')}
396
+ onPress={openMembers}
397
+ />
398
+ </GroupedSection>
399
+ ) : null}
400
+
401
+ {extraSections}
402
+
403
+ {canArchive ? (
404
+ <GroupedSection title={t('accounts.settings.sections.dangerZone')} footer={archive?.footer}>
405
+ <GroupedSectionItem
406
+ icon={<SettingsIcon name="archive-arrow-down" color={colors.error} />}
407
+ title={archive?.title ?? t('accounts.settings.archive.title')}
408
+ description={
409
+ archive?.title ? archive.description : t('accounts.settings.archive.subtitle')
410
+ }
411
+ destructive
412
+ disabled={archiveMutation.isPending}
413
+ onPress={() => void handleArchive()}
414
+ />
415
+ </GroupedSection>
416
+ ) : null}
417
+ </View>
418
+ );
419
+ };
420
+
421
+ export default AccountSettings;
@@ -0,0 +1,101 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import type { AccountNode } from '@oxy.so/core';
4
+ import { AccountSettings } from '../AccountSettings';
5
+
6
+ const mockGet = jest.fn();
7
+ const mockUpdate = jest.fn();
8
+ const mockArchive = jest.fn();
9
+ const mockServices = {
10
+ accounts: { get: mockGet, update: mockUpdate, archive: mockArchive },
11
+ assets: { publicUrl: (id: string) => `https://cdn.test/${id}` },
12
+ };
13
+ jest.mock('../../../context/oxyRuntimeContext', () => ({
14
+ useOptionalOxy: () => null,
15
+ useOxy: () => ({
16
+ oxyServices: mockServices,
17
+ canUsePrivateApi: true,
18
+ user: { id: 'viewer' },
19
+ accounts: [],
20
+ switchToAccount: jest.fn(),
21
+ showBottomSheet: jest.fn(),
22
+ }),
23
+ }));
24
+
25
+ function channel(permissions: string[]): AccountNode {
26
+ return {
27
+ accountId: 'chan',
28
+ kind: 'channel',
29
+ parentAccountId: null,
30
+ relationship: 'member',
31
+ callerMembership: { permissions } as AccountNode['callerMembership'],
32
+ account: {
33
+ id: 'chan',
34
+ kind: 'channel',
35
+ username: 'daily',
36
+ name: { displayName: 'Daily' },
37
+ bio: 'Every day',
38
+ accountCategories: ['news', 'politics'],
39
+ } as AccountNode['account'],
40
+ };
41
+ }
42
+
43
+ function renderSettings(props: Partial<Parameters<typeof AccountSettings>[0]> = {}) {
44
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
45
+ return render(
46
+ <QueryClientProvider client={client}>
47
+ <AccountSettings accountId="chan" {...props} />
48
+ </QueryClientProvider>,
49
+ );
50
+ }
51
+
52
+ beforeEach(() => jest.clearAllMocks());
53
+
54
+ test('saves the profile with its categories in order and hands the write to the app', async () => {
55
+ const node = channel(['account:update']);
56
+ mockGet.mockResolvedValue(node);
57
+ mockUpdate.mockResolvedValue(node);
58
+ const onUpdated = jest.fn();
59
+ renderSettings({ onUpdated, extraSections: <p>App section</p> });
60
+
61
+ fireEvent.click(await screen.findByText('Make primary'));
62
+ fireEvent.change(screen.getByLabelText('Bio (optional)'), { target: { value: '' } });
63
+ fireEvent.click(screen.getByText('Save changes'));
64
+
65
+ await waitFor(() =>
66
+ expect(mockUpdate).toHaveBeenCalledWith('chan', {
67
+ name: { displayName: 'Daily' },
68
+ bio: null,
69
+ accountCategories: ['politics', 'news'],
70
+ }),
71
+ );
72
+ await waitFor(() => expect(onUpdated).toHaveBeenCalledWith(node.account, { cleared: ['bio'] }));
73
+ expect(screen.getByText('App section')).toBeTruthy();
74
+ // Archiving is a separate right, and this member was not granted it.
75
+ expect(screen.queryByText('Archive account')).toBeNull();
76
+ });
77
+
78
+ test("archiving runs the app's confirmation and pre-archive step before Oxy's archive", async () => {
79
+ const order: string[] = [];
80
+ mockGet.mockResolvedValue(channel(['account:delete']));
81
+ mockArchive.mockImplementation(async () => order.push('archive'));
82
+ const onArchived = jest.fn();
83
+ renderSettings({
84
+ archive: {
85
+ title: 'Delete channel',
86
+ confirm: async () => {
87
+ order.push('confirm');
88
+ return true;
89
+ },
90
+ beforeArchive: async () => {
91
+ order.push('before');
92
+ },
93
+ onArchived,
94
+ },
95
+ });
96
+
97
+ fireEvent.click(await screen.findByText('Delete channel'));
98
+
99
+ await waitFor(() => expect(onArchived).toHaveBeenCalled());
100
+ expect(order).toEqual(['confirm', 'before', 'archive']);
101
+ });
@@ -2,7 +2,10 @@
2
2
  * Avatar Picker Hook
3
3
  *
4
4
  * The ONE write path for a user's profile picture. It owns no UI: the whole
5
- * choose-and-crop experience is a single surface.
5
+ * choose-and-crop experience is a single surface. The choosing half —
6
+ * {@link pickAvatarFile} — is account-agnostic and also serves a managed
7
+ * account's picture (`AccountSettings`), so there is one picker, one cropper and
8
+ * one upload for every account an app can edit.
6
9
  *
7
10
  * Flow (typed promise over the shared surface stack — no callback props):
8
11
  * 1. `openWithinOrPresent('ChangeAvatar')` opens the source list (device gallery,
@@ -27,7 +30,6 @@ import type { QueryClient } from '@tanstack/react-query';
27
30
  import { toast } from '@oxy.so/bloom/toast';
28
31
  import { updateProfileWithAvatar } from '../utils/avatarUtils';
29
32
  import { openWithinOrPresent } from '../navigation/surfaces';
30
- import type { AvatarCropResult } from '../screens/AvatarCropScreen';
31
33
 
32
34
  interface UseAvatarPickerOptions {
33
35
  oxyServices: OxyServices;
@@ -52,84 +54,78 @@ function extractUploadedFileId(result: AssetUploadResult | undefined): string |
52
54
  return undefined;
53
55
  }
54
56
 
57
+ /**
58
+ * Whose picture the picker is changing, when it is not the signed-in user's —
59
+ * the source list previews it and offers "Remove" only when there is one.
60
+ */
61
+ export interface AvatarPickerSubject {
62
+ avatar?: string | null;
63
+ displayName?: string;
64
+ }
65
+
66
+ /**
67
+ * The shared choose-and-crop flow, then the crop uploaded as a NEW public file.
68
+ *
69
+ * Resolves the new file's id, `null` when the person chose to remove the
70
+ * picture, or `undefined` when they backed out. It writes no account: the
71
+ * caller decides whose picture the file becomes, which is what lets the
72
+ * signed-in user's profile and a managed account share every step before that.
73
+ * An upload failure rejects.
74
+ */
75
+ export async function pickAvatarFile(
76
+ oxyServices: OxyServices,
77
+ subject?: AvatarPickerSubject,
78
+ ): Promise<string | null | undefined> {
79
+ // The source list + crop editor are ONE flow. When triggered from a screen
80
+ // already inside a surface (EditProfile, ManageAccount, WelcomeNewUser,
81
+ // AccountSettings), it MORPHS into that surface and pops back on finish;
82
+ // triggered with no surface open it presents cold. Either way it resolves with
83
+ // the cropped JPEG, a removal, or `undefined` when the user backs out.
84
+ const result = await openWithinOrPresent('ChangeAvatar', subject ? { subject } : undefined);
85
+ if (!result) return undefined;
86
+ if ('removed' in result) return null;
87
+ const uploadResult = (await oxyServices.assets.upload(
88
+ { uri: result.uri, type: result.mime, name: `avatar-${Date.now()}.jpg` },
89
+ { visibility: 'public' },
90
+ )) as AssetUploadResult;
91
+ const fileId = extractUploadedFileId(uploadResult);
92
+ if (!fileId) {
93
+ throw new Error('Avatar upload succeeded but no file ID was returned');
94
+ }
95
+ await updateAvatarVisibility(fileId, oxyServices, 'pickAvatarFile');
96
+ return fileId;
97
+ }
98
+
55
99
  export function useAvatarPicker({
56
100
  oxyServices,
57
101
  currentLanguage,
58
102
  activeSessionId,
59
103
  queryClient,
60
104
  }: UseAvatarPickerOptions) {
61
- /**
62
- * Final step: take the cropped JPEG, upload it as a new public file, then
63
- * set it as the user's avatar. Fires success/error toasts.
64
- */
65
- const finalizeCroppedAvatar = useCallback(
66
- async (cropped: AvatarCropResult) => {
67
- try {
68
- const uploadResult = (await oxyServices.assets.upload(
69
- {
70
- uri: cropped.uri,
71
- type: cropped.mime,
72
- name: `avatar-${Date.now()}.jpg`,
73
- },
74
- { visibility: 'public' },
75
- )) as AssetUploadResult;
76
-
77
- const newFileId = extractUploadedFileId(uploadResult);
78
- if (!newFileId) {
79
- throw new Error('Avatar upload succeeded but no file ID was returned');
80
- }
81
-
82
- await updateAvatarVisibility(newFileId, oxyServices, 'useAvatarPicker');
83
- await updateProfileWithAvatar(
84
- { avatar: newFileId },
85
- oxyServices,
86
- activeSessionId,
87
- queryClient,
88
- );
89
-
90
- toast.success(
91
- translate(currentLanguage ?? undefined, 'editProfile.toasts.avatarUpdated') ||
92
- 'Avatar updated',
93
- );
94
- } catch (e: unknown) {
95
- const message = e instanceof Error ? e.message : undefined;
96
- toast.error(
97
- message ||
98
- translate(currentLanguage ?? undefined, 'editProfile.toasts.updateAvatarFailed') ||
99
- 'Failed to update avatar',
100
- );
101
- }
102
- },
103
- [activeSessionId, currentLanguage, oxyServices, queryClient],
104
- );
105
-
106
- /** Clear the avatar so the profile falls back to the user's initials. */
107
- const removeAvatar = useCallback(async () => {
105
+ const openAvatarPicker = useCallback(async () => {
106
+ const locale = currentLanguage ?? undefined;
108
107
  try {
109
- await updateProfileWithAvatar({ avatar: '' }, oxyServices, activeSessionId, queryClient);
110
- toast.success(translate(currentLanguage ?? undefined, 'editProfile.toasts.avatarRemoved'));
108
+ const fileId = await pickAvatarFile(oxyServices);
109
+ if (fileId === undefined) return;
110
+ // `''` clears the field on `updateMe`, so the profile falls back to the
111
+ // user's initials.
112
+ await updateProfileWithAvatar(
113
+ { avatar: fileId ?? '' },
114
+ oxyServices,
115
+ activeSessionId,
116
+ queryClient,
117
+ );
118
+ toast.success(
119
+ translate(
120
+ locale,
121
+ fileId === null ? 'editProfile.toasts.avatarRemoved' : 'editProfile.toasts.avatarUpdated',
122
+ ),
123
+ );
111
124
  } catch (e: unknown) {
112
125
  const message = e instanceof Error ? e.message : undefined;
113
- toast.error(
114
- message || translate(currentLanguage ?? undefined, 'editProfile.toasts.updateAvatarFailed'),
115
- );
126
+ toast.error(message || translate(locale, 'editProfile.toasts.updateAvatarFailed'));
116
127
  }
117
128
  }, [activeSessionId, currentLanguage, oxyServices, queryClient]);
118
129
 
119
- const openAvatarPicker = useCallback(async () => {
120
- // The source list + crop editor are ONE flow. When triggered from a screen
121
- // already inside a surface (EditProfile, ManageAccount, WelcomeNewUser — every
122
- // current entry point), it MORPHS into that surface and pops back on finish;
123
- // triggered with no surface open it presents cold. Either way it resolves with
124
- // the cropped JPEG, a removal, or `undefined` when the user backs out.
125
- const result = await openWithinOrPresent('ChangeAvatar');
126
- if (!result) return;
127
- if ('removed' in result) {
128
- await removeAvatar();
129
- return;
130
- }
131
- await finalizeCroppedAvatar(result);
132
- }, [finalizeCroppedAvatar, removeAvatar]);
133
-
134
130
  return { openAvatarPicker };
135
131
  }