@oxy.so/services 12.10.1 → 12.11.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 (54) 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/index.js +7 -0
  12. package/lib/commonjs/ui/screens/index.js.map +1 -1
  13. package/lib/module/ui/components/accountSettings/AccountCategoriesSection.js +134 -0
  14. package/lib/module/ui/components/accountSettings/AccountCategoriesSection.js.map +1 -0
  15. package/lib/module/ui/components/accountSettings/AccountSettings.js +355 -0
  16. package/lib/module/ui/components/accountSettings/AccountSettings.js.map +1 -0
  17. package/lib/module/ui/hooks/useAvatarPicker.js +53 -48
  18. package/lib/module/ui/hooks/useAvatarPicker.js.map +1 -1
  19. package/lib/module/ui/screens/AccountSettingsScreen.js +16 -235
  20. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  21. package/lib/module/ui/screens/ChangeAvatarScreen.js +23 -4
  22. package/lib/module/ui/screens/ChangeAvatarScreen.js.map +1 -1
  23. package/lib/module/ui/screens/index.js +4 -0
  24. package/lib/module/ui/screens/index.js.map +1 -1
  25. package/lib/typescript/commonjs/ui/components/accountSettings/AccountCategoriesSection.d.ts +26 -0
  26. package/lib/typescript/commonjs/ui/components/accountSettings/AccountCategoriesSection.d.ts.map +1 -0
  27. package/lib/typescript/commonjs/ui/components/accountSettings/AccountSettings.d.ts +80 -0
  28. package/lib/typescript/commonjs/ui/components/accountSettings/AccountSettings.d.ts.map +1 -0
  29. package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts +22 -1
  30. package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts +4 -5
  32. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/ui/screens/index.d.ts +1 -0
  35. package/lib/typescript/commonjs/ui/screens/index.d.ts.map +1 -1
  36. package/lib/typescript/module/ui/components/accountSettings/AccountCategoriesSection.d.ts +26 -0
  37. package/lib/typescript/module/ui/components/accountSettings/AccountCategoriesSection.d.ts.map +1 -0
  38. package/lib/typescript/module/ui/components/accountSettings/AccountSettings.d.ts +80 -0
  39. package/lib/typescript/module/ui/components/accountSettings/AccountSettings.d.ts.map +1 -0
  40. package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts +22 -1
  41. package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts.map +1 -1
  42. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts +4 -5
  43. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  44. package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  45. package/lib/typescript/module/ui/screens/index.d.ts +1 -0
  46. package/lib/typescript/module/ui/screens/index.d.ts.map +1 -1
  47. package/package.json +4 -4
  48. package/src/ui/components/accountSettings/AccountCategoriesSection.tsx +180 -0
  49. package/src/ui/components/accountSettings/AccountSettings.tsx +421 -0
  50. package/src/ui/components/accountSettings/__tests__/AccountSettings.test.tsx +101 -0
  51. package/src/ui/hooks/useAvatarPicker.ts +65 -69
  52. package/src/ui/screens/AccountSettingsScreen.tsx +13 -277
  53. package/src/ui/screens/ChangeAvatarScreen.tsx +25 -6
  54. package/src/ui/screens/index.ts +8 -0
@@ -1,41 +1,17 @@
1
1
  import type React from 'react';
2
- import { OrganizationSettings } from '../components/organizations/OrganizationSettings';
3
- import { useCallback, useMemo, useState } from 'react';
4
- import { View } from 'react-native';
5
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
6
- import { toast } from '@oxy.so/bloom/toast';
7
- import { surfaces } from '@oxy.so/bloom/surfaces';
8
- import { useTheme } from '@oxy.so/bloom/theme';
9
- import { Text } from '@oxy.so/bloom/typography';
10
- import { Button } from '@oxy.so/bloom/button';
11
- import { TextField, TextFieldInput } from '@oxy.so/bloom/text-field';
12
- import { GroupedSection, GroupedSectionItem } from '@oxy.so/bloom/grouped-section';
13
- import {
14
- accountNameInvalidKey,
15
- accountNameInvalidMessage,
16
- accountNameMaxLength,
17
- getNormalizedUserHandle,
18
- isValidAccountName,
19
- type UpdateAccountInput,
20
- } from '@oxy.so/core';
21
2
  import type { BaseScreenProps } from '../types/navigation';
22
- import { SettingsIcon } from '../components/SettingsIcon';
23
- import { useOxy } from '../context/oxyRuntimeContext';
3
+ import {
4
+ AccountSettings,
5
+ useAccountSettingsNode,
6
+ } from '../components/accountSettings/AccountSettings';
24
7
  import { useI18n } from '../hooks/useI18n';
25
8
  import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
26
- import { clearedFieldsFromAccountUpdate, upsertCachedUser } from '../hooks/queries/userCache';
27
-
28
- const BIO_MAX = 160;
29
-
30
- const errorMessage = (error: unknown, fallback: string): string =>
31
- error instanceof Error && error.message ? error.message : fallback;
32
9
 
33
10
  /**
34
- * Per-account settings: edit the account's profile, jump to member management,
35
- * and archive the account (danger zone). Receives the target `accountId` via
36
- * navigation props. Self-surface for the caller's own personal account lives in
37
- * {@link ManageAccountScreen}; this screen manages a non-personal account in the
38
- * graph.
11
+ * The `AccountSettings` surface: {@link AccountSettings} for the `accountId`
12
+ * route prop, under the surface's own header. Self-surface for the caller's own
13
+ * personal account lives in {@link ManageAccountScreen}; this screen manages a
14
+ * non-personal account in the graph.
39
15
  */
40
16
  const AccountSettingsScreen: React.FC<BaseScreenProps> = ({
41
17
  onClose,
@@ -43,259 +19,19 @@ const AccountSettingsScreen: React.FC<BaseScreenProps> = ({
43
19
  navigate,
44
20
  accountId,
45
21
  }) => {
46
- const bloomTheme = useTheme();
47
- const colors = bloomTheme.colors;
48
22
  const { t } = useI18n();
49
-
50
- const { oxyServices, canUsePrivateApi, user, accounts, switchToAccount } = useOxy();
51
- const queryClient = useQueryClient();
52
-
53
23
  const id = typeof accountId === 'string' ? accountId : '';
54
-
55
- const accountQuery = useQuery({
56
- queryKey: ['accounts', 'detail', id, user?.id, canUsePrivateApi],
57
- initialData: canUsePrivateApi
58
- ? accounts?.find((account) => account.accountId === id)
59
- : undefined,
60
- initialDataUpdatedAt: 0,
61
- queryFn: () => oxyServices.accounts.get(id),
62
- enabled: canUsePrivateApi && id.length > 0,
63
- });
64
-
65
- const node = canUsePrivateApi && accountQuery.isSuccess ? accountQuery.data : null;
66
- const permissions = node?.callerMembership?.permissions ?? [];
67
- const isImplicitOwner = node ? node.relationship !== 'member' : false;
68
- const can = useCallback(
69
- (permission: string): boolean => isImplicitOwner || permissions.includes(permission),
70
- [isImplicitOwner, permissions],
71
- );
72
- const canUpdate = can('account:update');
73
- const canViewMembers = can('members:read');
74
- const canArchive = can('account:delete');
75
-
76
- // Editable fields, seeded lazily from the loaded account once.
77
- const [seeded, setSeeded] = useState(false);
78
- const [displayName, setDisplayName] = useState('');
79
- const [displayNameError, setDisplayNameError] = useState('');
80
- const [bio, setBio] = useState('');
81
-
82
- // Seed the form from the account during render (no useEffect): the first time
83
- // the query resolves we capture its values into local edit state.
84
- if (node && !seeded) {
85
- setDisplayName(node.account?.name?.displayName ?? getNormalizedUserHandle(node.account) ?? '');
86
- setBio(node.account?.bio ?? '');
87
- setSeeded(true);
88
- }
89
-
90
- const updateMutation = useMutation({
91
- mutationKey: ['accounts', 'update', id],
92
- mutationFn: (input: UpdateAccountInput) => oxyServices.accounts.update(id, input),
93
- onSuccess: (updatedNode, input) => {
94
- const cleared = clearedFieldsFromAccountUpdate(input);
95
- upsertCachedUser(queryClient, updatedNode.account, user?.id, {
96
- cleared: cleared.length > 0 ? cleared : undefined,
97
- });
98
- queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
99
- queryClient.invalidateQueries({ queryKey: ['accounts'] });
100
- toast.success(t('accounts.settings.toasts.saved') || 'Account updated');
101
- },
102
- onError: (error) => {
103
- toast.error(
104
- errorMessage(error, t('accounts.settings.toasts.saveFailed') || 'Failed to update account'),
105
- );
106
- },
107
- });
108
-
109
- const archiveMutation = useMutation({
110
- mutationKey: ['accounts', 'archive', id],
111
- mutationFn: () => oxyServices.accounts.archive(id),
112
- onSuccess: async () => {
113
- // If we archived the account we're currently signed in AS, switch back to
114
- // the personal account so the app isn't left as an archived identity.
115
- if (user?.id === id) {
116
- const personal = accounts.find((node) => node.relationship === 'self');
117
- if (personal) {
118
- await switchToAccount(personal.accountId).catch(() => undefined);
119
- }
120
- }
121
- queryClient.invalidateQueries({ queryKey: ['accounts'] });
122
- toast.success(t('accounts.settings.toasts.archived') || 'Account archived');
123
- (onClose ?? goBack)?.();
124
- },
125
- onError: (error) => {
126
- toast.error(
127
- errorMessage(
128
- error,
129
- t('accounts.settings.toasts.archiveFailed') || 'Failed to archive account',
130
- ),
131
- );
132
- },
133
- });
134
-
135
- const handleArchive = useCallback(async () => {
136
- const confirmed = await surfaces.confirm({
137
- title: t('accounts.settings.archive.confirmTitle') || 'Archive account',
138
- description:
139
- t('accounts.settings.archive.confirmDescription') ||
140
- 'Archive this account? It will be deactivated and its members will lose access.',
141
- confirmLabel: t('accounts.settings.archive.title') || 'Archive account',
142
- cancelLabel: t('common.cancel') || 'Cancel',
143
- destructive: true,
144
- });
145
- if (confirmed) archiveMutation.mutate();
146
- }, [archiveMutation, t]);
147
-
148
- const handleDisplayNameChange = useCallback(
149
- (value: string) => {
150
- setDisplayName(value);
151
- const trimmed = value.trim();
152
- if (!trimmed) {
153
- setDisplayNameError('');
154
- return;
155
- }
156
- const kind = node?.account.kind;
157
- setDisplayNameError(
158
- isValidAccountName(trimmed, kind)
159
- ? ''
160
- : t(`accounts.settings.displayName.${accountNameInvalidKey(kind)}`) ||
161
- accountNameInvalidMessage(kind),
162
- );
163
- },
164
- [node?.account.kind, t],
165
- );
166
-
167
- const handleSave = useCallback(() => {
168
- const trimmed = displayName.trim();
169
- if (!trimmed || displayNameError) return;
170
- // Managed accounts (org / project / bot / channel) have a title, not a
171
- // given-and-family name — persist the explicit displayName column.
172
- updateMutation.mutate({
173
- name: { displayName: trimmed },
174
- bio: bio.trim() ? bio.trim() : null,
175
- });
176
- }, [displayName, displayNameError, bio, updateMutation]);
177
-
178
- const title = t('accounts.settings.title') || 'Account settings';
179
-
180
- const accountHandle = useMemo(() => {
181
- const username = node?.account?.username;
182
- return username ? `@${username}` : '';
183
- }, [node?.account?.username]);
24
+ const { node } = useAccountSettingsNode(id);
184
25
 
185
26
  // The nav large title is the account's handle once loaded (falls back to the
186
27
  // generic screen title before the account resolves).
28
+ const username = node?.account?.username;
187
29
  useSurfaceHeader({
188
- title: accountHandle || title,
189
- subtitle:
190
- t('accounts.settings.subtitle') || 'Manage this account’s profile, members, and access.',
30
+ title: username ? `@${username}` : t('accounts.settings.title'),
31
+ subtitle: t('accounts.settings.subtitle'),
191
32
  });
192
33
 
193
- if (!id) {
194
- return (
195
- <>
196
- <View className="items-center justify-center px-screen-margin py-space-40">
197
- <Text className="text-body font-body text-text-secondary text-center">
198
- {t('accounts.settings.errors.missingAccount') || 'No account selected.'}
199
- </Text>
200
- </View>
201
- </>
202
- );
203
- }
204
-
205
- return (
206
- <>
207
- <View className="px-screen-margin pt-space-24 pb-space-32 gap-space-24">
208
- {accountQuery.isLoading ? (
209
- <View className="items-center py-space-32">{null}</View>
210
- ) : (
211
- <>
212
- {/* Profile edit */}
213
- {canUpdate ? (
214
- <View className="gap-space-16 p-space-16 rounded-radius-20 bg-fill">
215
- <View className="gap-space-4">
216
- <TextField isInvalid={Boolean(displayNameError)}>
217
- <TextFieldInput
218
- floatingLabel
219
- label={t('accounts.settings.displayName.label') || 'Display name'}
220
- value={displayName}
221
- onChangeText={handleDisplayNameChange}
222
- isInvalid={Boolean(displayNameError)}
223
- maxLength={accountNameMaxLength(node?.account.kind)}
224
- />
225
- </TextField>
226
- {displayNameError ? (
227
- <Text className="text-caption font-caption text-negative px-space-4">
228
- {displayNameError}
229
- </Text>
230
- ) : null}
231
- </View>
232
- <View className="gap-space-4">
233
- <TextField>
234
- <TextFieldInput
235
- floatingLabel
236
- label={t('accounts.settings.bio.label') || 'Bio (optional)'}
237
- value={bio}
238
- onChangeText={setBio}
239
- maxLength={BIO_MAX}
240
- multiline
241
- numberOfLines={3}
242
- textAlignVertical="top"
243
- />
244
- </TextField>
245
- <Text className="text-caption font-caption text-text-tertiary px-space-4 text-right">
246
- {bio.length}/{BIO_MAX}
247
- </Text>
248
- </View>
249
- <Button
250
- appearance="solid"
251
- tone="accent"
252
- onPress={handleSave}
253
- disabled={
254
- updateMutation.isPending || !displayName.trim() || Boolean(displayNameError)
255
- }
256
- loading={updateMutation.isPending}
257
- accessibilityLabel={t('accounts.settings.save') || 'Save changes'}
258
- className="w-full"
259
- >
260
- {t('accounts.settings.save') || 'Save changes'}
261
- </Button>
262
- </View>
263
- ) : null}
264
-
265
- {node?.account.kind === 'organization' ? (
266
- <OrganizationSettings key={id} accountId={id} canUpdate={canUpdate} />
267
- ) : null}
268
-
269
- {/* Members entry */}
270
- {canViewMembers ? (
271
- <GroupedSection title={t('accounts.settings.sections.access') || 'Access'}>
272
- <GroupedSectionItem
273
- icon={<SettingsIcon name="account-multiple" color={colors.info} />}
274
- title={t('accounts.members.title') || 'Members'}
275
- description={
276
- t('accounts.settings.members.subtitle') || 'Invite people and manage roles'
277
- }
278
- onPress={() => navigate?.('AccountMembers', { accountId: id })}
279
- />
280
- </GroupedSection>
281
- ) : null}
282
-
283
- {/* Danger zone */}
284
- {canArchive ? (
285
- <GroupedSection title={t('accounts.settings.sections.dangerZone') || 'Danger zone'}>
286
- <GroupedSectionItem
287
- icon={<SettingsIcon name="archive-arrow-down" color={colors.error} />}
288
- title={t('accounts.settings.archive.title') || 'Archive account'}
289
- description={t('accounts.settings.archive.subtitle') || 'Deactivate this account'}
290
- onPress={handleArchive}
291
- />
292
- </GroupedSection>
293
- ) : null}
294
- </>
295
- )}
296
- </View>
297
- </>
298
- );
34
+ return <AccountSettings accountId={id} navigate={navigate} onClose={onClose ?? goBack} />;
299
35
  };
300
36
 
301
37
  export default AccountSettingsScreen;
@@ -47,6 +47,7 @@ import { useI18n } from '../hooks/useI18n';
47
47
  import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
48
48
  import { SettingsIcon } from '../components/SettingsIcon';
49
49
  import type { BaseScreenProps } from '../types/navigation';
50
+ import type { AvatarPickerSubject } from '../hooks/useAvatarPicker';
50
51
 
51
52
  /** Component name used in `logger` context for filtered diagnostics. */
52
53
  const LOG_COMPONENT = 'ChangeAvatarScreen';
@@ -131,10 +132,25 @@ async function loadImagePicker(): Promise<ImagePickerModule | null> {
131
132
  }
132
133
  }
133
134
 
134
- const ChangeAvatarScreen: React.FC<BaseScreenProps> = ({ navigate, dismiss }) => {
135
+ /**
136
+ * The `subject` route prop, when a caller is changing a picture that is not the
137
+ * signed-in user's (see `pickAvatarFile`). Route props arrive untyped.
138
+ */
139
+ function readSubject(value: unknown): AvatarPickerSubject | null {
140
+ if (!value || typeof value !== 'object') return null;
141
+ const { avatar, displayName } = value as Record<string, unknown>;
142
+ return {
143
+ avatar: typeof avatar === 'string' && avatar.length > 0 ? avatar : null,
144
+ displayName: typeof displayName === 'string' ? displayName : undefined,
145
+ };
146
+ }
147
+
148
+ const ChangeAvatarScreen: React.FC<BaseScreenProps> = ({ navigate, dismiss, subject }) => {
135
149
  const bloomTheme = useTheme();
136
150
  const { t } = useI18n();
137
151
  const { user, oxyServices } = useOxy();
152
+ const other = useMemo(() => readSubject(subject), [subject]);
153
+ const currentAvatar = other ? other.avatar : user?.avatar;
138
154
 
139
155
  /** Set when a permission was refused — drives the inline denied notice. */
140
156
  const [denied, setDenied] = useState<DeniedSource | null>(null);
@@ -144,13 +160,16 @@ const ChangeAvatarScreen: React.FC<BaseScreenProps> = ({ navigate, dismiss }) =>
144
160
  useSurfaceHeader(useMemo(() => ({ title: t('changeAvatar.title') }), [t]));
145
161
 
146
162
  const displayName = useMemo(
147
- () => user?.name?.displayName ?? getNormalizedUserHandle(user) ?? '',
148
- [user],
163
+ () =>
164
+ other
165
+ ? (other.displayName ?? '')
166
+ : (user?.name?.displayName ?? getNormalizedUserHandle(user) ?? ''),
167
+ [other, user],
149
168
  );
150
169
  const avatarUri = useMemo(
151
170
  // `thumb` is 256x256 — exactly a 2x source for the 132dp hero.
152
- () => (user?.avatar ? oxyServices.assets.publicUrl(user.avatar, 'thumb') : undefined),
153
- [user?.avatar, oxyServices],
171
+ () => (currentAvatar ? oxyServices.assets.publicUrl(currentAvatar, 'thumb') : undefined),
172
+ [currentAvatar, oxyServices],
154
173
  );
155
174
 
156
175
  /**
@@ -362,7 +381,7 @@ const ChangeAvatarScreen: React.FC<BaseScreenProps> = ({ navigate, dismiss }) =>
362
381
  title={t('changeAvatar.sources.remove.title')}
363
382
  description={t('changeAvatar.sources.remove.description')}
364
383
  destructive
365
- disabled={busy || !user?.avatar}
384
+ disabled={busy || !currentAvatar}
366
385
  onPress={() => void removeCurrentPhoto()}
367
386
  />
368
387
  </GroupedSection>
@@ -18,3 +18,11 @@ export { default as AccountSettingsScreen } from './AccountSettingsScreen';
18
18
 
19
19
  export { default as SupervisionScreen } from './SupervisionScreen';
20
20
  export { default as OxyOneScreen } from './OxyOneScreen';
21
+
22
+ // The body of the `AccountSettings` surface, for an app embedding a managed
23
+ // account's settings in its own page with its own sections around them.
24
+ export {
25
+ AccountSettings,
26
+ type AccountSettingsProps,
27
+ type AccountSettingsArchiveOptions,
28
+ } from '../components/accountSettings/AccountSettings';