@oxy.so/services 11.8.0 → 11.10.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/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/lib/commonjs/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/lib/commonjs/assets/assets/fonts/icons/manifest.json +5 -4
- package/lib/commonjs/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/lib/commonjs/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/lib/commonjs/assets/fonts/icons/manifest.json +5 -4
- package/lib/commonjs/index.js +28 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/AccountInvitationAcceptance.js +97 -0
- package/lib/commonjs/ui/components/AccountInvitationAcceptance.js.map +1 -0
- package/lib/commonjs/ui/components/DisplayCurrencySelector.js +49 -0
- package/lib/commonjs/ui/components/DisplayCurrencySelector.js.map +1 -0
- package/lib/commonjs/ui/components/fileManagement/FileViewer.js +2 -1
- package/lib/commonjs/ui/components/fileManagement/FileViewer.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/queryKeys.js +3 -1
- package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/useFileQueries.js +39 -17
- package/lib/commonjs/ui/hooks/queries/useFileQueries.js.map +1 -1
- package/lib/commonjs/ui/hooks/useDisplayCurrency.js +77 -0
- package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +1 -0
- package/lib/commonjs/ui/hooks/useDisplayPrice.js +59 -0
- package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +1 -0
- package/lib/commonjs/ui/icons/subsetGlyphMaps.js +1 -0
- package/lib/commonjs/ui/icons/subsetGlyphMaps.js.map +1 -1
- package/lib/commonjs/ui/navigation/routes.js +1 -0
- package/lib/commonjs/ui/navigation/routes.js.map +1 -1
- package/lib/commonjs/ui/navigation/surfaceRegistry.js +2 -2
- package/lib/commonjs/ui/navigation/surfaceRegistry.js.map +1 -1
- package/lib/commonjs/ui/screens/AccountMembersScreen.js +63 -20
- package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/FileManagementScreen.js +102 -44
- package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/FileTrashScreen.js +235 -0
- package/lib/commonjs/ui/screens/FileTrashScreen.js.map +1 -0
- package/lib/commonjs/ui/screens/PreferencesScreen.js +4 -3
- package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/fileManagement/trashActions.js +147 -0
- package/lib/commonjs/ui/screens/fileManagement/trashActions.js.map +1 -0
- package/lib/commonjs/ui/stores/assetStore.js +22 -7
- package/lib/commonjs/ui/stores/assetStore.js.map +1 -1
- package/lib/module/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/lib/module/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/lib/module/assets/assets/fonts/icons/manifest.json +5 -4
- package/lib/module/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/lib/module/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/lib/module/assets/fonts/icons/manifest.json +5 -4
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/AccountInvitationAcceptance.js +94 -0
- package/lib/module/ui/components/AccountInvitationAcceptance.js.map +1 -0
- package/lib/module/ui/components/DisplayCurrencySelector.js +44 -0
- package/lib/module/ui/components/DisplayCurrencySelector.js.map +1 -0
- package/lib/module/ui/components/fileManagement/FileViewer.js +2 -1
- package/lib/module/ui/components/fileManagement/FileViewer.js.map +1 -1
- package/lib/module/ui/hooks/queries/queryKeys.js +3 -1
- package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
- package/lib/module/ui/hooks/queries/useFileQueries.js +38 -17
- package/lib/module/ui/hooks/queries/useFileQueries.js.map +1 -1
- package/lib/module/ui/hooks/useDisplayCurrency.js +72 -0
- package/lib/module/ui/hooks/useDisplayCurrency.js.map +1 -0
- package/lib/module/ui/hooks/useDisplayPrice.js +56 -0
- package/lib/module/ui/hooks/useDisplayPrice.js.map +1 -0
- package/lib/module/ui/icons/subsetGlyphMaps.js +1 -0
- package/lib/module/ui/icons/subsetGlyphMaps.js.map +1 -1
- package/lib/module/ui/navigation/routes.js +1 -0
- package/lib/module/ui/navigation/routes.js.map +1 -1
- package/lib/module/ui/navigation/surfaceRegistry.js +2 -2
- package/lib/module/ui/navigation/surfaceRegistry.js.map +1 -1
- package/lib/module/ui/screens/AccountMembersScreen.js +63 -20
- package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/module/ui/screens/FileManagementScreen.js +102 -44
- package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/module/ui/screens/FileTrashScreen.js +232 -0
- package/lib/module/ui/screens/FileTrashScreen.js.map +1 -0
- package/lib/module/ui/screens/PreferencesScreen.js +4 -3
- package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
- package/lib/module/ui/screens/fileManagement/trashActions.js +138 -0
- package/lib/module/ui/screens/fileManagement/trashActions.js.map +1 -0
- package/lib/module/ui/stores/assetStore.js +22 -7
- package/lib/module/ui/stores/assetStore.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +5 -0
- package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +3 -0
- package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/fileManagement/FileViewer.d.ts +3 -0
- package/lib/typescript/commonjs/ui/components/fileManagement/FileViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +2 -0
- package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts +22 -6
- package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +11 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +6 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
- package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts +1 -0
- package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts +2 -0
- package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/FileTrashScreen.d.ts +14 -0
- package/lib/typescript/commonjs/ui/screens/FileTrashScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/fileManagement/trashActions.d.ts +66 -0
- package/lib/typescript/commonjs/ui/screens/fileManagement/trashActions.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/stores/assetStore.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +5 -0
- package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +3 -0
- package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/fileManagement/FileViewer.d.ts +3 -0
- package/lib/typescript/module/ui/components/fileManagement/FileViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +2 -0
- package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts +22 -6
- package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +11 -0
- package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +6 -0
- package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +1 -0
- package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
- package/lib/typescript/module/ui/icons/Ionicons.d.ts.map +1 -1
- package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts +1 -0
- package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts +2 -0
- package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/FileTrashScreen.d.ts +14 -0
- package/lib/typescript/module/ui/screens/FileTrashScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/fileManagement/trashActions.d.ts +66 -0
- package/lib/typescript/module/ui/screens/fileManagement/trashActions.d.ts.map +1 -0
- package/lib/typescript/module/ui/stores/assetStore.d.ts.map +1 -1
- package/package.json +7 -5
- package/src/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/src/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/src/assets/fonts/icons/manifest.json +5 -4
- package/src/index.ts +5 -0
- package/src/ui/components/AccountInvitationAcceptance.tsx +90 -0
- package/src/ui/components/DisplayCurrencySelector.tsx +38 -0
- package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +61 -0
- package/src/ui/components/fileManagement/FileViewer.tsx +5 -1
- package/src/ui/hooks/queries/__tests__/fileQueries.test.ts +70 -0
- package/src/ui/hooks/queries/queryKeys.ts +2 -0
- package/src/ui/hooks/queries/useFileQueries.ts +45 -16
- package/src/ui/hooks/useDisplayCurrency.ts +64 -0
- package/src/ui/hooks/useDisplayPrice.ts +40 -0
- package/src/ui/icons/subsetGlyphMaps.ts +1 -0
- package/src/ui/navigation/__tests__/getSurfaceConfig.test.ts +9 -0
- package/src/ui/navigation/routes.ts +2 -0
- package/src/ui/navigation/surfaceRegistry.ts +4 -1
- package/src/ui/screens/AccountMembersScreen.tsx +71 -27
- package/src/ui/screens/FileManagementScreen.tsx +142 -49
- package/src/ui/screens/FileTrashScreen.tsx +228 -0
- package/src/ui/screens/PreferencesScreen.tsx +2 -0
- package/src/ui/screens/fileManagement/__tests__/trashActions.test.ts +177 -0
- package/src/ui/screens/fileManagement/trashActions.ts +161 -0
- package/src/ui/stores/assetStore.ts +24 -13
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { AccountInvitationAcceptance } from '../AccountInvitationAcceptance';
|
|
4
|
+
const mockPreview = jest.fn();
|
|
5
|
+
const mockAccept = jest.fn();
|
|
6
|
+
const mockSwitch = jest.fn();
|
|
7
|
+
let mockUser: { id: string; kind: string; email: string; username: string } | null = null;
|
|
8
|
+
const mockServices = { accounts: { invitations: { preview: mockPreview, accept: mockAccept } } };
|
|
9
|
+
jest.mock('../../context/OxyContext', () => ({
|
|
10
|
+
useOxy: () => ({
|
|
11
|
+
oxyServices: mockServices,
|
|
12
|
+
user: mockUser,
|
|
13
|
+
canUsePrivateApi: !!mockUser,
|
|
14
|
+
openAccountDialog: mockSwitch,
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
jest.clearAllMocks();
|
|
19
|
+
mockUser = null;
|
|
20
|
+
mockPreview.mockResolvedValue({
|
|
21
|
+
invitation: { role: 'editor' },
|
|
22
|
+
account: { displayName: 'Community' },
|
|
23
|
+
});
|
|
24
|
+
mockAccept.mockResolvedValue({ success: true });
|
|
25
|
+
});
|
|
26
|
+
test('sign-in and switching never accept; confirmation names the displayed selected account', async () => {
|
|
27
|
+
const { rerender } = render(<AccountInvitationAcceptance token="secret" />);
|
|
28
|
+
fireEvent.click(screen.getByText('Sign in or create account'));
|
|
29
|
+
expect(mockSwitch).toHaveBeenCalled();
|
|
30
|
+
expect(mockAccept).not.toHaveBeenCalled();
|
|
31
|
+
mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'different@example.com' };
|
|
32
|
+
rerender(<AccountInvitationAcceptance token="secret" />);
|
|
33
|
+
await screen.findByText('Join Community as editor.');
|
|
34
|
+
fireEvent.click(screen.getByText('Change account'));
|
|
35
|
+
expect(mockAccept).not.toHaveBeenCalled();
|
|
36
|
+
mockUser = { id: 'second', kind: 'personal', username: 'second', email: 'second@example.com' };
|
|
37
|
+
rerender(<AccountInvitationAcceptance token="secret" />);
|
|
38
|
+
expect(screen.getByText(/Selected account: second/)).toBeTruthy();
|
|
39
|
+
fireEvent.click(screen.getByText('Confirm and accept'));
|
|
40
|
+
await waitFor(() => expect(mockAccept).toHaveBeenCalledWith('secret', 'second'));
|
|
41
|
+
await screen.findByText('Invitation accepted. You can now access this account.');
|
|
42
|
+
});
|
|
43
|
+
test('a new link resets previous acceptance and does not auto-accept', async () => {
|
|
44
|
+
mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'first@example.com' };
|
|
45
|
+
const { rerender } = render(<AccountInvitationAcceptance token="old" />);
|
|
46
|
+
await screen.findByText('Join Community as editor.');
|
|
47
|
+
fireEvent.click(screen.getByText('Confirm and accept'));
|
|
48
|
+
await screen.findByText('Invitation accepted. You can now access this account.');
|
|
49
|
+
rerender(<AccountInvitationAcceptance token="new" />);
|
|
50
|
+
await screen.findByText('Join Community as editor.');
|
|
51
|
+
expect(screen.queryByText('Invitation accepted. You can now access this account.')).toBeNull();
|
|
52
|
+
expect(mockAccept).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
test('preview failure leaves confirmation disabled', async () => {
|
|
55
|
+
mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'first@example.com' };
|
|
56
|
+
mockPreview.mockRejectedValue(new Error('Invitation expired'));
|
|
57
|
+
render(<AccountInvitationAcceptance token="expired" />);
|
|
58
|
+
await screen.findByText('Invitation expired');
|
|
59
|
+
fireEvent.click(screen.getByText('Confirm and accept'));
|
|
60
|
+
expect(mockAccept).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
@@ -18,7 +18,10 @@ interface FileViewerProps {
|
|
|
18
18
|
onToggleDetails: () => void;
|
|
19
19
|
onClose: () => void;
|
|
20
20
|
onDownload: (fileId: string, filename: string) => void;
|
|
21
|
+
/** Removes the file from the library: to trash, or for good if it is blocked. */
|
|
21
22
|
onDelete: (fileId: string, filename: string) => void;
|
|
23
|
+
/** The remove button's label — "Move to trash", or "Delete" for a blocked file. */
|
|
24
|
+
deleteLabel: string;
|
|
22
25
|
isOwner: boolean;
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -55,6 +58,7 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
|
|
55
58
|
onClose,
|
|
56
59
|
onDownload,
|
|
57
60
|
onDelete,
|
|
61
|
+
deleteLabel,
|
|
58
62
|
isOwner,
|
|
59
63
|
}) => {
|
|
60
64
|
const { colors } = useTheme();
|
|
@@ -395,7 +399,7 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
|
|
395
399
|
onDelete(file.id, file.filename);
|
|
396
400
|
}}
|
|
397
401
|
>
|
|
398
|
-
|
|
402
|
+
{deleteLabel}
|
|
399
403
|
</Button>
|
|
400
404
|
</View>
|
|
401
405
|
)}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file library's page mapping and cache helpers for the Trash view (ADR
|
|
3
|
+
* 0043 D5): a trashed file keeps its `trashedAt`, and the library and the
|
|
4
|
+
* trash are separate cache entries under one `files` root.
|
|
5
|
+
*/
|
|
6
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
7
|
+
import { queryKeys } from '../queryKeys';
|
|
8
|
+
import {
|
|
9
|
+
mapRawFileToMetadata,
|
|
10
|
+
removeFileFromCache,
|
|
11
|
+
toUserFilesPage,
|
|
12
|
+
type UserFilesInfinite,
|
|
13
|
+
} from '../useFileQueries';
|
|
14
|
+
|
|
15
|
+
jest.mock('../../../context/OxyContext', () => ({ useOxy: () => ({}) }));
|
|
16
|
+
|
|
17
|
+
const page = (ids: string[]): UserFilesInfinite => ({
|
|
18
|
+
pages: [
|
|
19
|
+
{
|
|
20
|
+
files: ids.map((id) => mapRawFileToMetadata({ id, originalName: `${id}.png` })),
|
|
21
|
+
total: ids.length,
|
|
22
|
+
hasMore: false,
|
|
23
|
+
nextOffset: 40,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
pageParams: [0],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('mapRawFileToMetadata', () => {
|
|
30
|
+
it('keeps the status and when the file went to trash', () => {
|
|
31
|
+
const file = mapRawFileToMetadata({
|
|
32
|
+
id: 'f',
|
|
33
|
+
originalName: 'f.png',
|
|
34
|
+
status: 'trash',
|
|
35
|
+
trashedAt: '2026-10-16T10:00:00.000Z',
|
|
36
|
+
});
|
|
37
|
+
expect(file).toMatchObject({ status: 'trash', trashedAt: '2026-10-16T10:00:00.000Z' });
|
|
38
|
+
expect(mapRawFileToMetadata({ id: 'g', trashedAt: null })).not.toHaveProperty('trashedAt');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('toUserFilesPage', () => {
|
|
43
|
+
it('maps a page and where the next one starts', () => {
|
|
44
|
+
const mapped = toUserFilesPage({ files: [{ id: 'a' }], total: 41, hasMore: true }, 0);
|
|
45
|
+
expect(mapped).toMatchObject({ total: 41, hasMore: true, nextOffset: 40 });
|
|
46
|
+
expect(mapped.files.map((file) => file.id)).toEqual(['a']);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('the trash cache', () => {
|
|
51
|
+
it('is its own entry under files.all, and removal targets the list it names', () => {
|
|
52
|
+
expect(queryKeys.files.trash('u').slice(0, 1)).toEqual([...queryKeys.files.all]);
|
|
53
|
+
expect(queryKeys.files.trash('u')).not.toEqual(queryKeys.files.list('u'));
|
|
54
|
+
|
|
55
|
+
const client = new QueryClient();
|
|
56
|
+
client.setQueryData(queryKeys.files.list('u'), page(['a', 'b']));
|
|
57
|
+
client.setQueryData(queryKeys.files.trash('u'), page(['b', 't']));
|
|
58
|
+
|
|
59
|
+
removeFileFromCache(client, 'u', 'b', 'trash');
|
|
60
|
+
const ids = (key: readonly unknown[]) =>
|
|
61
|
+
client
|
|
62
|
+
.getQueryData<UserFilesInfinite>(key)
|
|
63
|
+
?.pages.flatMap((p) => p.files.map((file) => file.id));
|
|
64
|
+
expect(ids(queryKeys.files.trash('u'))).toEqual(['t']);
|
|
65
|
+
expect(ids(queryKeys.files.list('u'))).toEqual(['a', 'b']);
|
|
66
|
+
|
|
67
|
+
removeFileFromCache(client, 'u', 'a');
|
|
68
|
+
expect(ids(queryKeys.files.list('u'))).toEqual(['b']);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -113,6 +113,8 @@ export const queryKeys = {
|
|
|
113
113
|
files: {
|
|
114
114
|
all: ['files'] as const,
|
|
115
115
|
list: (ownerId?: string) => [...queryKeys.files.all, 'list', ownerId || 'anonymous'] as const,
|
|
116
|
+
/** The owner's trash (ADR 0043 D5) — under `files.all`, so one invalidation covers both. */
|
|
117
|
+
trash: (ownerId?: string) => [...queryKeys.files.all, 'trash', ownerId || 'anonymous'] as const,
|
|
116
118
|
},
|
|
117
119
|
|
|
118
120
|
// Connected apps (OAuth grants the user has authorized)
|
|
@@ -27,6 +27,12 @@ export interface RawUserFile {
|
|
|
27
27
|
createdAt?: string;
|
|
28
28
|
metadata?: Record<string, unknown>;
|
|
29
29
|
variants?: unknown[];
|
|
30
|
+
/** Whether any application uses the file (ADR 0043 D5). */
|
|
31
|
+
inUse?: boolean;
|
|
32
|
+
/** `blocked`: taken down after a content claim (ADR 0043 D10). */
|
|
33
|
+
status?: string;
|
|
34
|
+
/** When the owner moved it to trash (ADR 0043 D5). */
|
|
35
|
+
trashedAt?: string | null;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/** One page of the infinite file list. */
|
|
@@ -48,31 +54,52 @@ export function mapRawFileToMetadata(raw: RawUserFile): FileMetadata {
|
|
|
48
54
|
length: raw.size ?? 0,
|
|
49
55
|
chunkSize: 0,
|
|
50
56
|
uploadDate: raw.createdAt ?? '',
|
|
57
|
+
...(typeof raw.inUse === 'boolean' ? { inUse: raw.inUse } : {}),
|
|
58
|
+
...(raw.status === 'active' || raw.status === 'trash' || raw.status === 'blocked'
|
|
59
|
+
? { status: raw.status }
|
|
60
|
+
: {}),
|
|
61
|
+
...(typeof raw.trashedAt === 'string' ? { trashedAt: raw.trashedAt } : {}),
|
|
51
62
|
metadata: raw.metadata ?? {},
|
|
52
63
|
variants: (raw.variants ?? []) as FileMetadata['variants'],
|
|
53
64
|
};
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
/** Which of the owner's files a page lists: the library, or the trash (ADR 0043 D5). */
|
|
68
|
+
export type UserFilesListing = 'library' | 'trash';
|
|
69
|
+
|
|
70
|
+
/** Map one `oxyServices.assets.list` response onto a page. */
|
|
71
|
+
export function toUserFilesPage(
|
|
72
|
+
response: { files?: unknown[]; total?: number; hasMore?: boolean },
|
|
73
|
+
offset: number,
|
|
74
|
+
): UserFilesPage {
|
|
75
|
+
const files = ((response.files ?? []) as RawUserFile[]).map(mapRawFileToMetadata);
|
|
76
|
+
return {
|
|
77
|
+
files,
|
|
78
|
+
total: response.total ?? offset + files.length,
|
|
79
|
+
hasMore: Boolean(response.hasMore),
|
|
80
|
+
nextOffset: offset + FILES_PAGE_SIZE,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/**
|
|
57
|
-
* Infinite-paginated list of the owner's files
|
|
58
|
-
*
|
|
59
|
-
*
|
|
85
|
+
* Infinite-paginated list of the owner's files: the LIBRARY (active files, and
|
|
86
|
+
* blocked ones shown as removed after a claim) or, with `listing: 'trash'`,
|
|
87
|
+
* what the owner moved to trash, most recently trashed first. Fires on mount
|
|
88
|
+
* and whenever `ownerId` changes (a new key); `staleTime: 0` reloads on every
|
|
89
|
+
* open. The rendered list is `data.pages.flatMap(p => p.files)`.
|
|
60
90
|
*/
|
|
61
|
-
export const useUserFilesInfinite = (ownerId?: string) => {
|
|
91
|
+
export const useUserFilesInfinite = (ownerId?: string, listing: UserFilesListing = 'library') => {
|
|
62
92
|
const { oxyServices } = useOxy();
|
|
63
93
|
return useInfiniteQuery<UserFilesPage, Error>({
|
|
64
|
-
queryKey: queryKeys.files.list(ownerId),
|
|
94
|
+
queryKey: listing === 'trash' ? queryKeys.files.trash(ownerId) : queryKeys.files.list(ownerId),
|
|
65
95
|
queryFn: async ({ pageParam }) => {
|
|
66
96
|
const offset = typeof pageParam === 'number' ? pageParam : 0;
|
|
67
|
-
const response = await oxyServices.assets.list({
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
hasMore: Boolean(response.hasMore),
|
|
74
|
-
nextOffset: offset + FILES_PAGE_SIZE,
|
|
75
|
-
};
|
|
97
|
+
const response = await oxyServices.assets.list({
|
|
98
|
+
limit: FILES_PAGE_SIZE,
|
|
99
|
+
offset,
|
|
100
|
+
...(listing === 'trash' ? { status: 'trash' as const } : {}),
|
|
101
|
+
});
|
|
102
|
+
return toUserFilesPage(response, offset);
|
|
76
103
|
},
|
|
77
104
|
initialPageParam: 0,
|
|
78
105
|
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextOffset : undefined),
|
|
@@ -114,13 +141,15 @@ export function prependFileToCache(
|
|
|
114
141
|
});
|
|
115
142
|
}
|
|
116
143
|
|
|
117
|
-
/** Remove a file (by id) from every page. */
|
|
144
|
+
/** Remove a file (by id) from every page of the library, or of the trash. */
|
|
118
145
|
export function removeFileFromCache(
|
|
119
146
|
queryClient: QueryClient,
|
|
120
147
|
ownerId: string | undefined,
|
|
121
148
|
fileId: string,
|
|
149
|
+
listing: UserFilesListing = 'library',
|
|
122
150
|
): void {
|
|
123
|
-
|
|
151
|
+
const key = listing === 'trash' ? queryKeys.files.trash(ownerId) : fileListKey(ownerId);
|
|
152
|
+
queryClient.setQueryData<UserFilesInfinite>(key, (data) =>
|
|
124
153
|
data ? mapPages(data, (files) => files.filter((f) => f.id !== fileId)) : data,
|
|
125
154
|
);
|
|
126
155
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { authenticatedApiCall, type UserPreferences } from '@oxy.so/core';
|
|
4
|
+
import { useOxy } from '../context/OxyContext';
|
|
5
|
+
import { useCurrentUser } from './queries/useAccountQueries';
|
|
6
|
+
import { queryKeys } from './queries/queryKeys';
|
|
7
|
+
import { useAuthStore } from '../stores/authStore';
|
|
8
|
+
import { mutationKeys } from './mutations/mutationKeys';
|
|
9
|
+
|
|
10
|
+
export type DisplayCurrency = NonNullable<UserPreferences['displayCurrency']>;
|
|
11
|
+
export const DISPLAY_CURRENCIES: readonly DisplayCurrency[] = ['USD', 'EUR', 'FAIR'];
|
|
12
|
+
|
|
13
|
+
let anonymousCurrency: DisplayCurrency = 'USD';
|
|
14
|
+
const anonymousListeners = new Set<() => void>();
|
|
15
|
+
const subscribeAnonymous = (listener: () => void) => {
|
|
16
|
+
anonymousListeners.add(listener);
|
|
17
|
+
return () => {
|
|
18
|
+
anonymousListeners.delete(listener);
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
const anonymousSnapshot = () => anonymousCurrency;
|
|
22
|
+
|
|
23
|
+
/** Account-scoped server preference. A late save can never replace another account. */
|
|
24
|
+
export function useDisplayCurrency() {
|
|
25
|
+
const { oxyServices, activeSessionId, isAuthenticated, user } = useOxy();
|
|
26
|
+
const current = useCurrentUser({ enabled: isAuthenticated });
|
|
27
|
+
const client = useQueryClient();
|
|
28
|
+
const anonymous = useSyncExternalStore(
|
|
29
|
+
subscribeAnonymous,
|
|
30
|
+
anonymousSnapshot,
|
|
31
|
+
() => 'USD' as const,
|
|
32
|
+
);
|
|
33
|
+
const currency = isAuthenticated
|
|
34
|
+
? (current.data?.userPreferences?.displayCurrency ??
|
|
35
|
+
user?.userPreferences?.displayCurrency ??
|
|
36
|
+
'USD')
|
|
37
|
+
: anonymous;
|
|
38
|
+
const mutation = useMutation({
|
|
39
|
+
mutationKey: [...mutationKeys.account.updateSettings],
|
|
40
|
+
mutationFn: async (input: { currency: DisplayCurrency; sessionId: string; userId: string }) => {
|
|
41
|
+
const updated = await authenticatedApiCall(oxyServices, input.sessionId, () =>
|
|
42
|
+
oxyServices.users.updateMe({ userPreferences: { displayCurrency: input.currency } }),
|
|
43
|
+
);
|
|
44
|
+
if (updated.id !== input.userId) throw new Error('Account changed. Please try again.');
|
|
45
|
+
return updated;
|
|
46
|
+
},
|
|
47
|
+
onSuccess: (updated, input) => {
|
|
48
|
+
client.setQueryData(queryKeys.accounts.current(input.sessionId), updated);
|
|
49
|
+
if (useAuthStore.getState().user?.id === input.userId)
|
|
50
|
+
useAuthStore.getState().setUser(updated);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
function selectCurrency(next: DisplayCurrency) {
|
|
54
|
+
if (!DISPLAY_CURRENCIES.includes(next)) return;
|
|
55
|
+
if (!isAuthenticated) {
|
|
56
|
+
anonymousCurrency = next;
|
|
57
|
+
for (const listener of anonymousListeners) listener();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (activeSessionId && user?.id)
|
|
61
|
+
mutation.mutate({ currency: next, sessionId: activeSessionId, userId: user.id });
|
|
62
|
+
}
|
|
63
|
+
return { currency, selectCurrency, isSaving: mutation.isPending, error: mutation.error };
|
|
64
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { convertDisplayAmount, readDisplayRates } from '@peable.to/sdk/display';
|
|
4
|
+
import { useDisplayCurrency } from './useDisplayCurrency';
|
|
5
|
+
import { useI18n } from './useI18n';
|
|
6
|
+
|
|
7
|
+
/** Display conversion only. Pricing and checkout request bodies retain their source units. */
|
|
8
|
+
export function useDisplayPrice() {
|
|
9
|
+
const { currency } = useDisplayCurrency();
|
|
10
|
+
const { t } = useI18n();
|
|
11
|
+
const [now, setNow] = useState(Date.now);
|
|
12
|
+
const rates = useQuery({
|
|
13
|
+
queryKey: ['display-currency', 'faircoin-indexed-price'],
|
|
14
|
+
queryFn: ({ signal }) => readDisplayRates({ signal }),
|
|
15
|
+
enabled: currency !== 'USD',
|
|
16
|
+
staleTime: 30_000,
|
|
17
|
+
gcTime: 0,
|
|
18
|
+
retry: false,
|
|
19
|
+
refetchInterval: 60_000,
|
|
20
|
+
refetchOnWindowFocus: 'always',
|
|
21
|
+
});
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
24
|
+
return () => clearInterval(timer);
|
|
25
|
+
}, []);
|
|
26
|
+
function formatUsd(minorUnits: number) {
|
|
27
|
+
const display = convertDisplayAmount({
|
|
28
|
+
units: String(minorUnits),
|
|
29
|
+
currency: 'USD',
|
|
30
|
+
displayCurrency: currency,
|
|
31
|
+
rates: rates.isError ? null : rates.data,
|
|
32
|
+
now,
|
|
33
|
+
});
|
|
34
|
+
if (!display) return t('preferences.displayCurrency.unavailable', { currency });
|
|
35
|
+
return currency === 'USD'
|
|
36
|
+
? display
|
|
37
|
+
: t('preferences.displayCurrency.approximate', { amount: display });
|
|
38
|
+
}
|
|
39
|
+
return { currency, formatUsd };
|
|
40
|
+
}
|
|
@@ -32,6 +32,15 @@ describe('getSurfaceConfig presentation', () => {
|
|
|
32
32
|
expect(config.frameSize).toEqual({ heightRatio: 0.9, maxWidth: 640 });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it('hosts the file Trash as a morphing own-scroller sheet (it owns its list)', () => {
|
|
36
|
+
const config = getSurfaceConfig('FileTrash', {});
|
|
37
|
+
expect(config.presentation).toBe('sheet');
|
|
38
|
+
expect(config.scrollable).toBe(false);
|
|
39
|
+
expect(config.morph).toBe(true);
|
|
40
|
+
expect(config.stacks).toBe(false);
|
|
41
|
+
expect(config.header).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
35
44
|
it('morphs the surface for every route, the picker included', () => {
|
|
36
45
|
// NAV-WITHIN: drilling into these swaps the content inside ONE surface,
|
|
37
46
|
// which reshapes the panel instead of hard-cutting.
|
|
@@ -12,6 +12,7 @@ export type RouteName =
|
|
|
12
12
|
| 'PrivacySettings'
|
|
13
13
|
| 'SearchSettings'
|
|
14
14
|
| 'FileManagement'
|
|
15
|
+
| 'FileTrash' // The person's trash: restore, or delete permanently (ADR 0043 D5)
|
|
15
16
|
| 'HelpSupport'
|
|
16
17
|
| 'LegalDocuments'
|
|
17
18
|
| 'AppInfo'
|
|
@@ -81,6 +82,7 @@ const screenComponents: Record<RouteName, ComponentType<never>> = {
|
|
|
81
82
|
PrivacySettings: lazy(() => import('../screens/PrivacySettingsScreen')),
|
|
82
83
|
SearchSettings: lazy(() => import('../screens/SearchSettingsScreen')),
|
|
83
84
|
FileManagement: lazy(() => import('../screens/FileManagementScreen')),
|
|
85
|
+
FileTrash: lazy(() => import('../screens/FileTrashScreen')),
|
|
84
86
|
HelpSupport: lazy(() => import('../screens/HelpSupportScreen')),
|
|
85
87
|
LegalDocuments: lazy(() => import('../screens/LegalDocumentsScreen')),
|
|
86
88
|
AppInfo: lazy(() => import('../screens/AppInfoScreen')),
|
|
@@ -39,6 +39,8 @@ export interface SurfaceRegistry {
|
|
|
39
39
|
* keep their callback behaviour and this result goes unread.
|
|
40
40
|
*/
|
|
41
41
|
FileManagement: SurfaceRoute<Record<string, unknown>, FileMetadata>;
|
|
42
|
+
/** The person's trash (ADR 0043 D5), opened from the file manager. */
|
|
43
|
+
FileTrash: SurfaceRoute;
|
|
42
44
|
HelpSupport: SurfaceRoute;
|
|
43
45
|
LegalDocuments: SurfaceRoute;
|
|
44
46
|
AppInfo: SurfaceRoute;
|
|
@@ -233,7 +235,7 @@ const isFileManagementImageOnlyPicker = (props: Record<string, unknown>): boolea
|
|
|
233
235
|
* Routes whose screen owns its OWN scroll container — a FlatList /
|
|
234
236
|
* VirtualizedList (`ConnectedApps`, `TrustLeaderboard`, `FollowersList` /
|
|
235
237
|
* `FollowingList` via `UserListScreen`) or its own vertical `ScrollView`
|
|
236
|
-
* (`FileManagement`). These MUST opt out of the Dialog's internal ScrollView
|
|
238
|
+
* (`FileManagement`, `FileTrash`). These MUST opt out of the Dialog's internal ScrollView
|
|
237
239
|
* (`scrollable: false`) so there is exactly ONE scroll container — otherwise a
|
|
238
240
|
* VirtualizedList nests inside a plain ScrollView (RN warning) or a nested
|
|
239
241
|
* vertical scroll-in-scroll breaks windowing. Screens with only a HORIZONTAL
|
|
@@ -242,6 +244,7 @@ const isFileManagementImageOnlyPicker = (props: Record<string, unknown>): boolea
|
|
|
242
244
|
*/
|
|
243
245
|
const OWN_SCROLL_CONTAINER_ROUTES: ReadonlySet<RouteName> = new Set<RouteName>([
|
|
244
246
|
'FileManagement',
|
|
247
|
+
'FileTrash',
|
|
245
248
|
'ConnectedApps',
|
|
246
249
|
'TrustLeaderboard',
|
|
247
250
|
'FollowersList',
|
|
@@ -30,13 +30,8 @@ const ASSIGNABLE_ROLES: AssignableRole[] = ['admin', 'editor', 'developer', 'bil
|
|
|
30
30
|
const errorMessage = (error: unknown, fallback: string): string =>
|
|
31
31
|
error instanceof Error && error.message ? error.message : fallback;
|
|
32
32
|
|
|
33
|
-
const isUserNotFound = (error: unknown): boolean => {
|
|
34
|
-
const message = error instanceof Error ? error.message.toLowerCase() : '';
|
|
35
|
-
return message.includes('user not found') || message.includes('not found');
|
|
36
|
-
};
|
|
37
|
-
|
|
38
33
|
/**
|
|
39
|
-
* Manage the members of an account (invite by
|
|
34
|
+
* Manage the members of an account (invite by email, change roles,
|
|
40
35
|
* remove, transfer ownership). Gated on the caller's `callerMembership`
|
|
41
36
|
* permissions. Receives the target `accountId` via navigation props.
|
|
42
37
|
*/
|
|
@@ -86,6 +81,27 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
|
|
|
86
81
|
queryFn: () => oxyServices.accounts.members.list(id),
|
|
87
82
|
enabled: canUsePrivateApi && id.length > 0 && canRead,
|
|
88
83
|
});
|
|
84
|
+
const invitationsQuery = useQuery({
|
|
85
|
+
queryKey: ['account-invitations', id],
|
|
86
|
+
queryFn: () => oxyServices.accounts.invitations.list(id),
|
|
87
|
+
enabled: canUsePrivateApi && !!id && canRead,
|
|
88
|
+
});
|
|
89
|
+
const invitationAction = useMutation({
|
|
90
|
+
mutationFn: async ({
|
|
91
|
+
invitationId,
|
|
92
|
+
action,
|
|
93
|
+
}: {
|
|
94
|
+
invitationId: string;
|
|
95
|
+
action: 'resend' | 'cancel';
|
|
96
|
+
}) => {
|
|
97
|
+
if (action === 'resend') await oxyServices.accounts.invitations.resend(id, invitationId);
|
|
98
|
+
else await oxyServices.accounts.invitations.cancel(id, invitationId);
|
|
99
|
+
},
|
|
100
|
+
onSuccess: () => {
|
|
101
|
+
void invitationsQuery.refetch();
|
|
102
|
+
},
|
|
103
|
+
onError: (error) => toast.error(errorMessage(error, 'Could not update invitation')),
|
|
104
|
+
});
|
|
89
105
|
const members = useMemo<AccountMember[]>(() => membersQuery.data ?? [], [membersQuery.data]);
|
|
90
106
|
const memberUserIds = useMemo(
|
|
91
107
|
() => [...new Set(members.map((member) => member.memberUserId).filter((id) => id.length > 0))],
|
|
@@ -126,6 +142,7 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
|
|
|
126
142
|
// tables — mirror the SDK's per-account prefix sweep so every cached member
|
|
127
143
|
// list refetches, not only the account that was mutated.
|
|
128
144
|
queryClient.invalidateQueries({ queryKey: ['accounts', 'members'] });
|
|
145
|
+
queryClient.invalidateQueries({ queryKey: ['account-invitations', id] });
|
|
129
146
|
queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
|
|
130
147
|
}, [queryClient, id]);
|
|
131
148
|
|
|
@@ -137,24 +154,20 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
|
|
|
137
154
|
const inviteMutation = useMutation({
|
|
138
155
|
mutationKey: ['accounts', 'members', 'invite', id],
|
|
139
156
|
mutationFn: (input: { usernameOrEmail: string; role: AssignableRole }) =>
|
|
140
|
-
oxyServices.accounts.
|
|
157
|
+
oxyServices.accounts.invitations.create(id, {
|
|
158
|
+
email: input.usernameOrEmail,
|
|
159
|
+
role: input.role,
|
|
160
|
+
}),
|
|
141
161
|
onSuccess: () => {
|
|
142
162
|
setInviteIdentifier('');
|
|
143
163
|
setInviteRole('editor');
|
|
144
164
|
setInviteError(null);
|
|
145
165
|
invalidateMembers();
|
|
146
|
-
toast.success(
|
|
166
|
+
toast.success('Invitation sent');
|
|
147
167
|
},
|
|
148
168
|
onError: (error) => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
setInviteError(message);
|
|
152
|
-
toast.error(message);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
toast.error(
|
|
156
|
-
errorMessage(error, t('accounts.members.toasts.addFailed') || 'Failed to add member'),
|
|
157
|
-
);
|
|
169
|
+
invalidateMembers();
|
|
170
|
+
toast.error(errorMessage(error, 'Could not send invitation'));
|
|
158
171
|
},
|
|
159
172
|
});
|
|
160
173
|
|
|
@@ -207,14 +220,12 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
|
|
|
207
220
|
const handleInvite = useCallback(() => {
|
|
208
221
|
const usernameOrEmail = inviteIdentifier.trim();
|
|
209
222
|
if (!usernameOrEmail) {
|
|
210
|
-
setInviteError(
|
|
211
|
-
t('accounts.members.errors.identifierRequired') || 'Enter a username or email to invite',
|
|
212
|
-
);
|
|
223
|
+
setInviteError('Enter an email to invite');
|
|
213
224
|
return;
|
|
214
225
|
}
|
|
215
226
|
setInviteError(null);
|
|
216
227
|
inviteMutation.mutate({ usernameOrEmail, role: inviteRole });
|
|
217
|
-
}, [inviteIdentifier, inviteRole, inviteMutation
|
|
228
|
+
}, [inviteIdentifier, inviteRole, inviteMutation]);
|
|
218
229
|
|
|
219
230
|
const handleChangeRole = useCallback(
|
|
220
231
|
(member: AccountMember, role: AccountRole) => {
|
|
@@ -282,13 +293,11 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
|
|
|
282
293
|
{/* Invite form */}
|
|
283
294
|
{canInvite ? (
|
|
284
295
|
<View className="gap-space-12 p-space-16 rounded-radius-20 bg-fill">
|
|
285
|
-
<Text className="text-body font-semibold text-text">
|
|
286
|
-
{t('accounts.members.invite.title') || 'Add a member'}
|
|
287
|
-
</Text>
|
|
296
|
+
<Text className="text-body font-semibold text-text">{'Invite a member'}</Text>
|
|
288
297
|
<TextField isInvalid={!!inviteError}>
|
|
289
298
|
<TextFieldInput
|
|
290
299
|
floatingLabel
|
|
291
|
-
label={
|
|
300
|
+
label={'Email'}
|
|
292
301
|
value={inviteIdentifier}
|
|
293
302
|
onChangeText={(value) => {
|
|
294
303
|
setInviteIdentifier(value);
|
|
@@ -332,14 +341,49 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
|
|
|
332
341
|
onPress={handleInvite}
|
|
333
342
|
disabled={inviteMutation.isPending || !inviteIdentifier.trim()}
|
|
334
343
|
loading={inviteMutation.isPending}
|
|
335
|
-
accessibilityLabel={
|
|
344
|
+
accessibilityLabel={'Send invitation'}
|
|
336
345
|
className="w-full"
|
|
337
346
|
>
|
|
338
|
-
{
|
|
347
|
+
{'Send invitation'}
|
|
339
348
|
</Button>
|
|
340
349
|
</View>
|
|
341
350
|
) : null}
|
|
342
351
|
|
|
352
|
+
{canRead ? (
|
|
353
|
+
<View className="gap-space-12">
|
|
354
|
+
<Text className="text-body font-semibold text-text">Pending invitations</Text>
|
|
355
|
+
{invitationsQuery.isError ? <Text>Could not load invitations.</Text> : null}
|
|
356
|
+
{(invitationsQuery.data ?? []).map((invitation) => (
|
|
357
|
+
<View key={invitation.id} className="gap-space-8 p-space-16 rounded-radius-20 bg-fill">
|
|
358
|
+
<Text>
|
|
359
|
+
{invitation.email} · {roleLabel(invitation.role)} · {invitation.status}
|
|
360
|
+
</Text>
|
|
361
|
+
<Text>Expires {new Date(invitation.expiresAt).toLocaleDateString()}</Text>
|
|
362
|
+
{canInvite ? (
|
|
363
|
+
<Button
|
|
364
|
+
disabled={invitationAction.isPending}
|
|
365
|
+
onPress={() =>
|
|
366
|
+
invitationAction.mutate({ invitationId: invitation.id, action: 'resend' })
|
|
367
|
+
}
|
|
368
|
+
>
|
|
369
|
+
Resend
|
|
370
|
+
</Button>
|
|
371
|
+
) : null}
|
|
372
|
+
{canRemove ? (
|
|
373
|
+
<Button
|
|
374
|
+
disabled={invitationAction.isPending}
|
|
375
|
+
onPress={() =>
|
|
376
|
+
invitationAction.mutate({ invitationId: invitation.id, action: 'cancel' })
|
|
377
|
+
}
|
|
378
|
+
>
|
|
379
|
+
Cancel invitation
|
|
380
|
+
</Button>
|
|
381
|
+
) : null}
|
|
382
|
+
</View>
|
|
383
|
+
))}
|
|
384
|
+
</View>
|
|
385
|
+
) : null}
|
|
386
|
+
|
|
343
387
|
{/* Member list */}
|
|
344
388
|
{canRead ? (
|
|
345
389
|
<View className="gap-space-8">
|