@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,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
|
+
});
|
|
@@ -999,8 +999,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
999
999
|
if (host) {
|
|
1000
1000
|
accountDialogHostRef.current = null;
|
|
1001
1001
|
setAccountDialogOpen(false);
|
|
1002
|
-
host.
|
|
1003
|
-
return;
|
|
1002
|
+
// A host that is already gone cannot pop; fall through to the detached surface.
|
|
1003
|
+
if (host.goBack()) return;
|
|
1004
1004
|
}
|
|
1005
1005
|
accountDialogSurfaceRef.current?.dismiss();
|
|
1006
1006
|
}, []);
|
|
@@ -1161,7 +1161,16 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1161
1161
|
accountDialogControllerRef.current?.setView(nextView);
|
|
1162
1162
|
|
|
1163
1163
|
// Its own detached surface is already open → just re-point the view above.
|
|
1164
|
+
// A route surface (e.g. ManageAccount) may sit ON TOP of it, so re-pointing the
|
|
1165
|
+
// view is not enough: bring the AccountDialog frame back to the front too.
|
|
1164
1166
|
if (accountDialogSurfaceRef.current) {
|
|
1167
|
+
const top = topRouteSurface();
|
|
1168
|
+
if (top) {
|
|
1169
|
+
accountDialogHostRef.current = top;
|
|
1170
|
+
top.navigate('AccountDialog', { initialView: nextView });
|
|
1171
|
+
} else {
|
|
1172
|
+
accountDialogSurfaceRef.current.navigate('AccountDialog', { initialView: nextView });
|
|
1173
|
+
}
|
|
1165
1174
|
setAccountDialogOpen(true);
|
|
1166
1175
|
return;
|
|
1167
1176
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
}
|
|
@@ -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 {
|
|
23
|
-
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
148
|
-
|
|
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
|
-
() => (
|
|
153
|
-
[
|
|
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 || !
|
|
384
|
+
disabled={busy || !currentAvatar}
|
|
366
385
|
onPress={() => void removeCurrentPhoto()}
|
|
367
386
|
/>
|
|
368
387
|
</GroupedSection>
|