@oxy.so/services 12.10.0 → 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.
- package/lib/commonjs/ui/components/OxyAuthChooser.js +8 -1
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/commonjs/ui/components/accountSettings/AccountCategoriesSection.js +142 -0
- package/lib/commonjs/ui/components/accountSettings/AccountCategoriesSection.js.map +1 -0
- package/lib/commonjs/ui/components/accountSettings/AccountSettings.js +361 -0
- package/lib/commonjs/ui/components/accountSettings/AccountSettings.js.map +1 -0
- package/lib/commonjs/ui/context/OxyContext.js +15 -2
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/useAvatarPicker.js +54 -48
- package/lib/commonjs/ui/hooks/useAvatarPicker.js.map +1 -1
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js +16 -237
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/ChangeAvatarScreen.js +23 -4
- package/lib/commonjs/ui/screens/ChangeAvatarScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/ManageAccountScreen.js +11 -3
- package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/index.js +7 -0
- package/lib/commonjs/ui/screens/index.js.map +1 -1
- package/lib/module/ui/components/OxyAuthChooser.js +8 -1
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/module/ui/components/accountSettings/AccountCategoriesSection.js +134 -0
- package/lib/module/ui/components/accountSettings/AccountCategoriesSection.js.map +1 -0
- package/lib/module/ui/components/accountSettings/AccountSettings.js +355 -0
- package/lib/module/ui/components/accountSettings/AccountSettings.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js +15 -2
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/hooks/useAvatarPicker.js +53 -48
- package/lib/module/ui/hooks/useAvatarPicker.js.map +1 -1
- package/lib/module/ui/screens/AccountSettingsScreen.js +16 -235
- package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
- package/lib/module/ui/screens/ChangeAvatarScreen.js +23 -4
- package/lib/module/ui/screens/ChangeAvatarScreen.js.map +1 -1
- package/lib/module/ui/screens/ManageAccountScreen.js +11 -3
- package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/module/ui/screens/index.js +4 -0
- package/lib/module/ui/screens/index.js.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/accountSettings/AccountCategoriesSection.d.ts +26 -0
- package/lib/typescript/commonjs/ui/components/accountSettings/AccountCategoriesSection.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/accountSettings/AccountSettings.d.ts +80 -0
- package/lib/typescript/commonjs/ui/components/accountSettings/AccountSettings.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts +22 -1
- package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts +4 -5
- package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/index.d.ts +1 -0
- package/lib/typescript/commonjs/ui/screens/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/accountSettings/AccountCategoriesSection.d.ts +26 -0
- package/lib/typescript/module/ui/components/accountSettings/AccountCategoriesSection.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/accountSettings/AccountSettings.d.ts +80 -0
- package/lib/typescript/module/ui/components/accountSettings/AccountSettings.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts +22 -1
- package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts +4 -5
- package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/index.d.ts +1 -0
- package/lib/typescript/module/ui/screens/index.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ui/components/OxyAuthChooser.tsx +9 -1
- package/src/ui/components/accountSettings/AccountCategoriesSection.tsx +180 -0
- package/src/ui/components/accountSettings/AccountSettings.tsx +421 -0
- package/src/ui/components/accountSettings/__tests__/AccountSettings.test.tsx +101 -0
- package/src/ui/context/OxyContext.tsx +11 -2
- package/src/ui/hooks/useAvatarPicker.ts +65 -69
- package/src/ui/screens/AccountSettingsScreen.tsx +13 -277
- package/src/ui/screens/ChangeAvatarScreen.tsx +25 -6
- package/src/ui/screens/ManageAccountScreen.tsx +26 -17
- package/src/ui/screens/index.ts +8 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useCallback, useState } from 'react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import { Button, CloseButton } from '@oxy.so/bloom/button';
|
|
5
|
+
import { GroupedSection, GroupedSectionItem } from '@oxy.so/bloom/grouped-section';
|
|
6
|
+
import { useTheme } from '@oxy.so/bloom/theme';
|
|
7
|
+
import {
|
|
8
|
+
ACCOUNT_CATEGORY_IDS,
|
|
9
|
+
MAX_ACCOUNT_CATEGORIES,
|
|
10
|
+
SELECTABLE_ACCOUNT_CATEGORY_IDS,
|
|
11
|
+
accountCategoryLabel,
|
|
12
|
+
type AccountCategoryId,
|
|
13
|
+
} from '@oxy.so/core';
|
|
14
|
+
import MaterialCommunityIcons from '../../icons/MaterialCommunityIcons';
|
|
15
|
+
import { SettingsIcon } from '../SettingsIcon';
|
|
16
|
+
import { useI18n } from '../../hooks/useI18n';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An account's categories are an ORDERED list whose element 0 is the primary —
|
|
20
|
+
* nothing else records which one is. So nothing here sorts: a choice joins at
|
|
21
|
+
* the end (choosing a second category must not demote the first), "Make
|
|
22
|
+
* primary" moves one to the front, and two lists are equal only in the same
|
|
23
|
+
* order (a promotion changes nothing BUT the order).
|
|
24
|
+
*/
|
|
25
|
+
export function toggleAccountCategory(
|
|
26
|
+
current: readonly AccountCategoryId[],
|
|
27
|
+
id: AccountCategoryId,
|
|
28
|
+
): AccountCategoryId[] {
|
|
29
|
+
if (current.includes(id)) return current.filter((entry) => entry !== id);
|
|
30
|
+
if (current.length >= MAX_ACCOUNT_CATEGORIES) return [...current];
|
|
31
|
+
return [...current, id];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function promoteAccountCategory(
|
|
35
|
+
current: readonly AccountCategoryId[],
|
|
36
|
+
id: AccountCategoryId,
|
|
37
|
+
): AccountCategoryId[] {
|
|
38
|
+
if (!current.includes(id)) return [...current];
|
|
39
|
+
return [id, ...current.filter((entry) => entry !== id)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function accountCategoriesEqual(
|
|
43
|
+
a: readonly AccountCategoryId[],
|
|
44
|
+
b: readonly AccountCategoryId[],
|
|
45
|
+
): boolean {
|
|
46
|
+
return a.length === b.length && a.every((entry, index) => entry === b[index]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A stored id can be one this build has never heard of — the vocabulary grows
|
|
51
|
+
* at Oxy, and an older client meets the newer id. It keeps its row (it counts
|
|
52
|
+
* against the cap and survives a save) but is never printed raw.
|
|
53
|
+
*/
|
|
54
|
+
const KNOWN_IDS: ReadonlySet<string> = new Set(ACCOUNT_CATEGORY_IDS);
|
|
55
|
+
|
|
56
|
+
interface AccountCategoriesSectionProps {
|
|
57
|
+
value: readonly AccountCategoryId[];
|
|
58
|
+
onChange: (next: AccountCategoryId[]) => void;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The categories editor of `AccountSettings`: the chosen categories in their
|
|
64
|
+
* stored order (primary first), and a collapsible list of the vocabulary to add
|
|
65
|
+
* from. Only SELECTABLE ids are offered — a withdrawn one stays on an account
|
|
66
|
+
* that carries it, but Oxy refuses newly adding it.
|
|
67
|
+
*/
|
|
68
|
+
export const AccountCategoriesSection: React.FC<AccountCategoriesSectionProps> = ({
|
|
69
|
+
value,
|
|
70
|
+
onChange,
|
|
71
|
+
disabled = false,
|
|
72
|
+
}) => {
|
|
73
|
+
const { colors } = useTheme();
|
|
74
|
+
const { t, locale } = useI18n();
|
|
75
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
76
|
+
const atCap = value.length >= MAX_ACCOUNT_CATEGORIES;
|
|
77
|
+
|
|
78
|
+
const labelOf = useCallback(
|
|
79
|
+
(id: AccountCategoryId) =>
|
|
80
|
+
KNOWN_IDS.has(id)
|
|
81
|
+
? accountCategoryLabel(locale, id)
|
|
82
|
+
: t('accounts.settings.categories.unavailable'),
|
|
83
|
+
[locale, t],
|
|
84
|
+
);
|
|
85
|
+
const toggle = useCallback(
|
|
86
|
+
(id: AccountCategoryId) => onChange(toggleAccountCategory(value, id)),
|
|
87
|
+
[onChange, value],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<View className="gap-space-8">
|
|
92
|
+
<GroupedSection
|
|
93
|
+
title={t('accounts.settings.categories.title')}
|
|
94
|
+
footer={t('accounts.settings.categories.footer', { max: MAX_ACCOUNT_CATEGORIES })}
|
|
95
|
+
>
|
|
96
|
+
{value.map((id, index) => {
|
|
97
|
+
const label = labelOf(id);
|
|
98
|
+
return (
|
|
99
|
+
<GroupedSectionItem
|
|
100
|
+
key={id}
|
|
101
|
+
icon={
|
|
102
|
+
<SettingsIcon
|
|
103
|
+
name={index === 0 ? 'star' : 'tag-outline'}
|
|
104
|
+
color={index === 0 ? colors.primary : colors.textSecondary}
|
|
105
|
+
/>
|
|
106
|
+
}
|
|
107
|
+
title={label}
|
|
108
|
+
description={index === 0 ? t('accounts.settings.categories.primary') : undefined}
|
|
109
|
+
showChevron={false}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
rightElement={
|
|
112
|
+
<View className="flex-row items-center gap-space-8">
|
|
113
|
+
{index > 0 ? (
|
|
114
|
+
<Button
|
|
115
|
+
appearance="subtle"
|
|
116
|
+
size="xs"
|
|
117
|
+
disabled={disabled}
|
|
118
|
+
onPress={() => onChange(promoteAccountCategory(value, id))}
|
|
119
|
+
>
|
|
120
|
+
{t('accounts.settings.categories.makePrimary')}
|
|
121
|
+
</Button>
|
|
122
|
+
) : null}
|
|
123
|
+
<CloseButton
|
|
124
|
+
size="xs"
|
|
125
|
+
disabled={disabled}
|
|
126
|
+
onPress={() => toggle(id)}
|
|
127
|
+
accessibilityLabel={t('accounts.settings.categories.remove', {
|
|
128
|
+
category: label,
|
|
129
|
+
})}
|
|
130
|
+
/>
|
|
131
|
+
</View>
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
<GroupedSectionItem
|
|
137
|
+
icon={<SettingsIcon name="plus" color={colors.textSecondary} />}
|
|
138
|
+
title={t('accounts.settings.categories.add')}
|
|
139
|
+
description={
|
|
140
|
+
atCap
|
|
141
|
+
? t('accounts.settings.categories.atCap', { max: MAX_ACCOUNT_CATEGORIES })
|
|
142
|
+
: undefined
|
|
143
|
+
}
|
|
144
|
+
disabled={disabled || atCap}
|
|
145
|
+
showChevron={false}
|
|
146
|
+
rightElement={
|
|
147
|
+
<MaterialCommunityIcons
|
|
148
|
+
name={pickerOpen ? 'chevron-up' : 'chevron-down'}
|
|
149
|
+
size={20}
|
|
150
|
+
color={colors.textSecondary}
|
|
151
|
+
/>
|
|
152
|
+
}
|
|
153
|
+
onPress={() => setPickerOpen((open) => !open)}
|
|
154
|
+
/>
|
|
155
|
+
</GroupedSection>
|
|
156
|
+
|
|
157
|
+
{pickerOpen && !atCap ? (
|
|
158
|
+
<GroupedSection>
|
|
159
|
+
{SELECTABLE_ACCOUNT_CATEGORY_IDS.map((id) => {
|
|
160
|
+
const selected = value.includes(id);
|
|
161
|
+
return (
|
|
162
|
+
<GroupedSectionItem
|
|
163
|
+
key={id}
|
|
164
|
+
title={accountCategoryLabel(locale, id)}
|
|
165
|
+
showChevron={false}
|
|
166
|
+
disabled={disabled}
|
|
167
|
+
rightElement={
|
|
168
|
+
selected ? (
|
|
169
|
+
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary} />
|
|
170
|
+
) : undefined
|
|
171
|
+
}
|
|
172
|
+
onPress={() => toggle(id)}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</GroupedSection>
|
|
177
|
+
) : null}
|
|
178
|
+
</View>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
@@ -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;
|