@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.
Files changed (179) hide show
  1. package/lib/commonjs/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  2. package/lib/commonjs/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  3. package/lib/commonjs/assets/assets/fonts/icons/manifest.json +5 -4
  4. package/lib/commonjs/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  5. package/lib/commonjs/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  6. package/lib/commonjs/assets/fonts/icons/manifest.json +5 -4
  7. package/lib/commonjs/index.js +28 -0
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js +97 -0
  10. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js.map +1 -0
  11. package/lib/commonjs/ui/components/DisplayCurrencySelector.js +49 -0
  12. package/lib/commonjs/ui/components/DisplayCurrencySelector.js.map +1 -0
  13. package/lib/commonjs/ui/components/fileManagement/FileViewer.js +2 -1
  14. package/lib/commonjs/ui/components/fileManagement/FileViewer.js.map +1 -1
  15. package/lib/commonjs/ui/hooks/queries/queryKeys.js +3 -1
  16. package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/queries/useFileQueries.js +39 -17
  18. package/lib/commonjs/ui/hooks/queries/useFileQueries.js.map +1 -1
  19. package/lib/commonjs/ui/hooks/useDisplayCurrency.js +77 -0
  20. package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +1 -0
  21. package/lib/commonjs/ui/hooks/useDisplayPrice.js +59 -0
  22. package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +1 -0
  23. package/lib/commonjs/ui/icons/subsetGlyphMaps.js +1 -0
  24. package/lib/commonjs/ui/icons/subsetGlyphMaps.js.map +1 -1
  25. package/lib/commonjs/ui/navigation/routes.js +1 -0
  26. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  27. package/lib/commonjs/ui/navigation/surfaceRegistry.js +2 -2
  28. package/lib/commonjs/ui/navigation/surfaceRegistry.js.map +1 -1
  29. package/lib/commonjs/ui/screens/AccountMembersScreen.js +63 -20
  30. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  31. package/lib/commonjs/ui/screens/FileManagementScreen.js +102 -44
  32. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  33. package/lib/commonjs/ui/screens/FileTrashScreen.js +235 -0
  34. package/lib/commonjs/ui/screens/FileTrashScreen.js.map +1 -0
  35. package/lib/commonjs/ui/screens/PreferencesScreen.js +4 -3
  36. package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
  37. package/lib/commonjs/ui/screens/fileManagement/trashActions.js +147 -0
  38. package/lib/commonjs/ui/screens/fileManagement/trashActions.js.map +1 -0
  39. package/lib/commonjs/ui/stores/assetStore.js +22 -7
  40. package/lib/commonjs/ui/stores/assetStore.js.map +1 -1
  41. package/lib/module/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  42. package/lib/module/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  43. package/lib/module/assets/assets/fonts/icons/manifest.json +5 -4
  44. package/lib/module/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  45. package/lib/module/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  46. package/lib/module/assets/fonts/icons/manifest.json +5 -4
  47. package/lib/module/index.js +4 -0
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/ui/components/AccountInvitationAcceptance.js +94 -0
  50. package/lib/module/ui/components/AccountInvitationAcceptance.js.map +1 -0
  51. package/lib/module/ui/components/DisplayCurrencySelector.js +44 -0
  52. package/lib/module/ui/components/DisplayCurrencySelector.js.map +1 -0
  53. package/lib/module/ui/components/fileManagement/FileViewer.js +2 -1
  54. package/lib/module/ui/components/fileManagement/FileViewer.js.map +1 -1
  55. package/lib/module/ui/hooks/queries/queryKeys.js +3 -1
  56. package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
  57. package/lib/module/ui/hooks/queries/useFileQueries.js +38 -17
  58. package/lib/module/ui/hooks/queries/useFileQueries.js.map +1 -1
  59. package/lib/module/ui/hooks/useDisplayCurrency.js +72 -0
  60. package/lib/module/ui/hooks/useDisplayCurrency.js.map +1 -0
  61. package/lib/module/ui/hooks/useDisplayPrice.js +56 -0
  62. package/lib/module/ui/hooks/useDisplayPrice.js.map +1 -0
  63. package/lib/module/ui/icons/subsetGlyphMaps.js +1 -0
  64. package/lib/module/ui/icons/subsetGlyphMaps.js.map +1 -1
  65. package/lib/module/ui/navigation/routes.js +1 -0
  66. package/lib/module/ui/navigation/routes.js.map +1 -1
  67. package/lib/module/ui/navigation/surfaceRegistry.js +2 -2
  68. package/lib/module/ui/navigation/surfaceRegistry.js.map +1 -1
  69. package/lib/module/ui/screens/AccountMembersScreen.js +63 -20
  70. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  71. package/lib/module/ui/screens/FileManagementScreen.js +102 -44
  72. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  73. package/lib/module/ui/screens/FileTrashScreen.js +232 -0
  74. package/lib/module/ui/screens/FileTrashScreen.js.map +1 -0
  75. package/lib/module/ui/screens/PreferencesScreen.js +4 -3
  76. package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
  77. package/lib/module/ui/screens/fileManagement/trashActions.js +138 -0
  78. package/lib/module/ui/screens/fileManagement/trashActions.js.map +1 -0
  79. package/lib/module/ui/stores/assetStore.js +22 -7
  80. package/lib/module/ui/stores/assetStore.js.map +1 -1
  81. package/lib/typescript/commonjs/index.d.ts +4 -0
  82. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  84. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +3 -0
  86. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/ui/components/fileManagement/FileViewer.d.ts +3 -0
  88. package/lib/typescript/commonjs/ui/components/fileManagement/FileViewer.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
  90. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +2 -0
  92. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts +22 -6
  94. package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
  96. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +11 -0
  98. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  99. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +6 -0
  100. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
  102. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts +1 -0
  104. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
  106. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts +2 -0
  108. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
  110. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/screens/FileTrashScreen.d.ts +14 -0
  113. package/lib/typescript/commonjs/ui/screens/FileTrashScreen.d.ts.map +1 -0
  114. package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/screens/fileManagement/trashActions.d.ts +66 -0
  116. package/lib/typescript/commonjs/ui/screens/fileManagement/trashActions.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/ui/stores/assetStore.d.ts.map +1 -1
  118. package/lib/typescript/module/index.d.ts +4 -0
  119. package/lib/typescript/module/index.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  121. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  122. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +3 -0
  123. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  124. package/lib/typescript/module/ui/components/fileManagement/FileViewer.d.ts +3 -0
  125. package/lib/typescript/module/ui/components/fileManagement/FileViewer.d.ts.map +1 -1
  126. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
  127. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  128. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +2 -0
  129. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts +22 -6
  131. package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
  133. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  134. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +11 -0
  135. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  136. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +6 -0
  137. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  138. package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
  139. package/lib/typescript/module/ui/icons/Ionicons.d.ts.map +1 -1
  140. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts +1 -0
  141. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
  143. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  144. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts +2 -0
  145. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts.map +1 -1
  146. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
  147. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  149. package/lib/typescript/module/ui/screens/FileTrashScreen.d.ts +14 -0
  150. package/lib/typescript/module/ui/screens/FileTrashScreen.d.ts.map +1 -0
  151. package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
  152. package/lib/typescript/module/ui/screens/fileManagement/trashActions.d.ts +66 -0
  153. package/lib/typescript/module/ui/screens/fileManagement/trashActions.d.ts.map +1 -0
  154. package/lib/typescript/module/ui/stores/assetStore.d.ts.map +1 -1
  155. package/package.json +7 -5
  156. package/src/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  157. package/src/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  158. package/src/assets/fonts/icons/manifest.json +5 -4
  159. package/src/index.ts +5 -0
  160. package/src/ui/components/AccountInvitationAcceptance.tsx +90 -0
  161. package/src/ui/components/DisplayCurrencySelector.tsx +38 -0
  162. package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +61 -0
  163. package/src/ui/components/fileManagement/FileViewer.tsx +5 -1
  164. package/src/ui/hooks/queries/__tests__/fileQueries.test.ts +70 -0
  165. package/src/ui/hooks/queries/queryKeys.ts +2 -0
  166. package/src/ui/hooks/queries/useFileQueries.ts +45 -16
  167. package/src/ui/hooks/useDisplayCurrency.ts +64 -0
  168. package/src/ui/hooks/useDisplayPrice.ts +40 -0
  169. package/src/ui/icons/subsetGlyphMaps.ts +1 -0
  170. package/src/ui/navigation/__tests__/getSurfaceConfig.test.ts +9 -0
  171. package/src/ui/navigation/routes.ts +2 -0
  172. package/src/ui/navigation/surfaceRegistry.ts +4 -1
  173. package/src/ui/screens/AccountMembersScreen.tsx +71 -27
  174. package/src/ui/screens/FileManagementScreen.tsx +142 -49
  175. package/src/ui/screens/FileTrashScreen.tsx +228 -0
  176. package/src/ui/screens/PreferencesScreen.tsx +2 -0
  177. package/src/ui/screens/fileManagement/__tests__/trashActions.test.ts +177 -0
  178. package/src/ui/screens/fileManagement/trashActions.ts +161 -0
  179. 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
- Delete
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. Fires on mount and whenever
58
- * `ownerId` changes (a new key); `staleTime: 0` reloads on every open. The
59
- * rendered list is `data.pages.flatMap(p => p.files)`.
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({ limit: FILES_PAGE_SIZE, offset });
68
- const raw = (response.files ?? []) as RawUserFile[];
69
- const files = raw.map(mapRawFileToMetadata);
70
- return {
71
- files,
72
- total: response.total ?? offset + files.length,
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
- queryClient.setQueryData<UserFilesInfinite>(fileListKey(ownerId), (data) =>
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
+ }
@@ -9,6 +9,7 @@ export const ioniconsGlyphMap = {
9
9
  "arrow-back": 61735,
10
10
  "arrow-down": 61741,
11
11
  "arrow-forward": 61747,
12
+ "arrow-undo": 61759,
12
13
  "arrow-up": 61765,
13
14
  "at": 61771,
14
15
  "briefcase-outline": 61874,
@@ -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 username/email, change roles,
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.members.invite(id, input),
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(t('accounts.members.toasts.added') || 'Member added');
166
+ toast.success('Invitation sent');
147
167
  },
148
168
  onError: (error) => {
149
- if (isUserNotFound(error)) {
150
- const message = t('accounts.members.errors.userNotFound') || 'User not found';
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, t]);
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={t('accounts.members.invite.identifierLabel') || 'Username or email'}
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={t('accounts.members.invite.submit') || 'Add member'}
344
+ accessibilityLabel={'Send invitation'}
336
345
  className="w-full"
337
346
  >
338
- {t('accounts.members.invite.submit') || 'Add member'}
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">