@oxy.so/services 11.9.0 → 11.12.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 (210) 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 +45 -0
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/ui/appAssertion/AppAssertionContext.js +79 -0
  10. package/lib/commonjs/ui/appAssertion/AppAssertionContext.js.map +1 -0
  11. package/lib/commonjs/ui/appAssertion/appAssertionManager.js +309 -0
  12. package/lib/commonjs/ui/appAssertion/appAssertionManager.js.map +1 -0
  13. package/lib/commonjs/ui/appAssertion/appIntegrityPlatform.js +18 -0
  14. package/lib/commonjs/ui/appAssertion/appIntegrityPlatform.js.map +1 -0
  15. package/lib/commonjs/ui/appAssertion/appIntegrityPlatform.native.js +30 -0
  16. package/lib/commonjs/ui/appAssertion/appIntegrityPlatform.native.js.map +1 -0
  17. package/lib/commonjs/ui/appAssertion/appIntegrityTypes.js +6 -0
  18. package/lib/commonjs/ui/appAssertion/appIntegrityTypes.js.map +1 -0
  19. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js +97 -0
  20. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js.map +1 -0
  21. package/lib/commonjs/ui/components/DisplayCurrencySelector.js +49 -0
  22. package/lib/commonjs/ui/components/DisplayCurrencySelector.js.map +1 -0
  23. package/lib/commonjs/ui/components/fileManagement/FileAppAccessSection.js +121 -0
  24. package/lib/commonjs/ui/components/fileManagement/FileAppAccessSection.js.map +1 -0
  25. package/lib/commonjs/ui/components/fileManagement/FileDetailsModal.js +6 -2
  26. package/lib/commonjs/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  27. package/lib/commonjs/ui/context/OxyContext.js +10 -1
  28. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  29. package/lib/commonjs/ui/hooks/mutations/mutationKeys.js +4 -0
  30. package/lib/commonjs/ui/hooks/mutations/mutationKeys.js.map +1 -1
  31. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js +49 -1
  32. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  33. package/lib/commonjs/ui/hooks/queries/queryKeys.js +6 -1
  34. package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
  35. package/lib/commonjs/ui/hooks/queries/useFileQueries.js +35 -1
  36. package/lib/commonjs/ui/hooks/queries/useFileQueries.js.map +1 -1
  37. package/lib/commonjs/ui/hooks/useDisplayCurrency.js +77 -0
  38. package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +1 -0
  39. package/lib/commonjs/ui/hooks/useDisplayPrice.js +59 -0
  40. package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +1 -0
  41. package/lib/commonjs/ui/icons/subsetGlyphMaps.js +1 -0
  42. package/lib/commonjs/ui/icons/subsetGlyphMaps.js.map +1 -1
  43. package/lib/commonjs/ui/index.js +7 -0
  44. package/lib/commonjs/ui/index.js.map +1 -1
  45. package/lib/commonjs/ui/screens/AccountMembersScreen.js +63 -20
  46. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  47. package/lib/commonjs/ui/screens/PreferencesScreen.js +4 -3
  48. package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
  49. package/lib/module/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  50. package/lib/module/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  51. package/lib/module/assets/assets/fonts/icons/manifest.json +5 -4
  52. package/lib/module/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  53. package/lib/module/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  54. package/lib/module/assets/fonts/icons/manifest.json +5 -4
  55. package/lib/module/index.js +8 -0
  56. package/lib/module/index.js.map +1 -1
  57. package/lib/module/ui/appAssertion/AppAssertionContext.js +72 -0
  58. package/lib/module/ui/appAssertion/AppAssertionContext.js.map +1 -0
  59. package/lib/module/ui/appAssertion/appAssertionManager.js +304 -0
  60. package/lib/module/ui/appAssertion/appAssertionManager.js.map +1 -0
  61. package/lib/module/ui/appAssertion/appIntegrityPlatform.js +14 -0
  62. package/lib/module/ui/appAssertion/appIntegrityPlatform.js.map +1 -0
  63. package/lib/module/ui/appAssertion/appIntegrityPlatform.native.js +26 -0
  64. package/lib/module/ui/appAssertion/appIntegrityPlatform.native.js.map +1 -0
  65. package/lib/module/ui/appAssertion/appIntegrityTypes.js +4 -0
  66. package/lib/module/ui/appAssertion/appIntegrityTypes.js.map +1 -0
  67. package/lib/module/ui/components/AccountInvitationAcceptance.js +94 -0
  68. package/lib/module/ui/components/AccountInvitationAcceptance.js.map +1 -0
  69. package/lib/module/ui/components/DisplayCurrencySelector.js +44 -0
  70. package/lib/module/ui/components/DisplayCurrencySelector.js.map +1 -0
  71. package/lib/module/ui/components/fileManagement/FileAppAccessSection.js +116 -0
  72. package/lib/module/ui/components/fileManagement/FileAppAccessSection.js.map +1 -0
  73. package/lib/module/ui/components/fileManagement/FileDetailsModal.js +6 -2
  74. package/lib/module/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  75. package/lib/module/ui/context/OxyContext.js +10 -1
  76. package/lib/module/ui/context/OxyContext.js.map +1 -1
  77. package/lib/module/ui/hooks/mutations/mutationKeys.js +4 -0
  78. package/lib/module/ui/hooks/mutations/mutationKeys.js.map +1 -1
  79. package/lib/module/ui/hooks/mutations/useAccountMutations.js +47 -0
  80. package/lib/module/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  81. package/lib/module/ui/hooks/queries/queryKeys.js +6 -1
  82. package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
  83. package/lib/module/ui/hooks/queries/useFileQueries.js +33 -1
  84. package/lib/module/ui/hooks/queries/useFileQueries.js.map +1 -1
  85. package/lib/module/ui/hooks/useDisplayCurrency.js +72 -0
  86. package/lib/module/ui/hooks/useDisplayCurrency.js.map +1 -0
  87. package/lib/module/ui/hooks/useDisplayPrice.js +56 -0
  88. package/lib/module/ui/hooks/useDisplayPrice.js.map +1 -0
  89. package/lib/module/ui/icons/subsetGlyphMaps.js +1 -0
  90. package/lib/module/ui/icons/subsetGlyphMaps.js.map +1 -1
  91. package/lib/module/ui/index.js +1 -0
  92. package/lib/module/ui/index.js.map +1 -1
  93. package/lib/module/ui/screens/AccountMembersScreen.js +63 -20
  94. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  95. package/lib/module/ui/screens/PreferencesScreen.js +4 -3
  96. package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
  97. package/lib/typescript/commonjs/index.d.ts +7 -0
  98. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/appAssertion/AppAssertionContext.d.ts +41 -0
  100. package/lib/typescript/commonjs/ui/appAssertion/AppAssertionContext.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/ui/appAssertion/appAssertionManager.d.ts +92 -0
  102. package/lib/typescript/commonjs/ui/appAssertion/appAssertionManager.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.d.ts +9 -0
  104. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.native.d.ts +3 -0
  106. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.native.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityTypes.d.ts +22 -0
  108. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityTypes.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  110. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +3 -0
  112. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/ui/components/fileManagement/FileAppAccessSection.d.ts +15 -0
  114. package/lib/typescript/commonjs/ui/components/fileManagement/FileAppAccessSection.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/ui/components/fileManagement/FileDetailsModal.d.ts +2 -1
  116. package/lib/typescript/commonjs/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/ui/hooks/mutations/mutationKeys.d.ts +3 -0
  119. package/lib/typescript/commonjs/ui/hooks/mutations/mutationKeys.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +20 -2
  121. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +5 -0
  123. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts +11 -1
  125. package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
  127. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +11 -0
  129. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +6 -0
  131. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  133. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  134. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts +1 -0
  135. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  136. package/lib/typescript/commonjs/ui/index.d.ts +2 -0
  137. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
  139. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
  141. package/lib/typescript/module/index.d.ts +7 -0
  142. package/lib/typescript/module/index.d.ts.map +1 -1
  143. package/lib/typescript/module/ui/appAssertion/AppAssertionContext.d.ts +41 -0
  144. package/lib/typescript/module/ui/appAssertion/AppAssertionContext.d.ts.map +1 -0
  145. package/lib/typescript/module/ui/appAssertion/appAssertionManager.d.ts +92 -0
  146. package/lib/typescript/module/ui/appAssertion/appAssertionManager.d.ts.map +1 -0
  147. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.d.ts +9 -0
  148. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.d.ts.map +1 -0
  149. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.native.d.ts +3 -0
  150. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.native.d.ts.map +1 -0
  151. package/lib/typescript/module/ui/appAssertion/appIntegrityTypes.d.ts +22 -0
  152. package/lib/typescript/module/ui/appAssertion/appIntegrityTypes.d.ts.map +1 -0
  153. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  154. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  155. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +3 -0
  156. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  157. package/lib/typescript/module/ui/components/fileManagement/FileAppAccessSection.d.ts +15 -0
  158. package/lib/typescript/module/ui/components/fileManagement/FileAppAccessSection.d.ts.map +1 -0
  159. package/lib/typescript/module/ui/components/fileManagement/FileDetailsModal.d.ts +2 -1
  160. package/lib/typescript/module/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  161. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  162. package/lib/typescript/module/ui/hooks/mutations/mutationKeys.d.ts +3 -0
  163. package/lib/typescript/module/ui/hooks/mutations/mutationKeys.d.ts.map +1 -1
  164. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +20 -2
  165. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  166. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +5 -0
  167. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  168. package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts +11 -1
  169. package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
  170. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
  171. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  172. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +11 -0
  173. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  174. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +6 -0
  175. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  176. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  177. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  178. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts +1 -0
  179. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  180. package/lib/typescript/module/ui/index.d.ts +2 -0
  181. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  182. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
  183. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  184. package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
  185. package/package.json +9 -6
  186. package/src/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  187. package/src/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  188. package/src/assets/fonts/icons/manifest.json +5 -4
  189. package/src/index.ts +17 -0
  190. package/src/ui/appAssertion/AppAssertionContext.tsx +109 -0
  191. package/src/ui/appAssertion/appAssertionManager.ts +363 -0
  192. package/src/ui/appAssertion/appIntegrityPlatform.native.ts +27 -0
  193. package/src/ui/appAssertion/appIntegrityPlatform.ts +12 -0
  194. package/src/ui/appAssertion/appIntegrityTypes.ts +23 -0
  195. package/src/ui/components/AccountInvitationAcceptance.tsx +90 -0
  196. package/src/ui/components/DisplayCurrencySelector.tsx +38 -0
  197. package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +61 -0
  198. package/src/ui/components/fileManagement/FileAppAccessSection.tsx +111 -0
  199. package/src/ui/components/fileManagement/FileDetailsModal.tsx +5 -1
  200. package/src/ui/context/OxyContext.tsx +13 -1
  201. package/src/ui/hooks/mutations/mutationKeys.ts +5 -0
  202. package/src/ui/hooks/mutations/useAccountMutations.ts +33 -0
  203. package/src/ui/hooks/queries/queryKeys.ts +5 -0
  204. package/src/ui/hooks/queries/useFileQueries.ts +44 -2
  205. package/src/ui/hooks/useDisplayCurrency.ts +64 -0
  206. package/src/ui/hooks/useDisplayPrice.ts +40 -0
  207. package/src/ui/icons/subsetGlyphMaps.ts +1 -0
  208. package/src/ui/index.ts +2 -0
  209. package/src/ui/screens/AccountMembersScreen.tsx +71 -27
  210. package/src/ui/screens/PreferencesScreen.tsx +2 -0
@@ -0,0 +1,111 @@
1
+ import type React from 'react';
2
+ import { useCallback, useState } from 'react';
3
+ import { ActivityIndicator, View } from 'react-native';
4
+ import { Avatar } from '@oxy.so/bloom/avatar';
5
+ import { Button } from '@oxy.so/bloom/button';
6
+ import { SettingsListGroup, SettingsListItem } from '@oxy.so/bloom/settings-list';
7
+ import { surfaces } from '@oxy.so/bloom/surfaces';
8
+ import { useTheme } from '@oxy.so/bloom/theme';
9
+ import { toast } from '@oxy.so/bloom/toast';
10
+ import { Text } from '@oxy.so/bloom/typography';
11
+ import type { FileApplicationAccess } from '@oxy.so/core';
12
+ import { useI18n } from '../../hooks/useI18n';
13
+ import { useFileAppAccess } from '../../hooks/queries/useFileQueries';
14
+ import { useRevokeFileAppAccess } from '../../hooks/mutations/useAccountMutations';
15
+
16
+ const APP_ICON_SIZE = 32;
17
+
18
+ interface FileAppAccessSectionProps {
19
+ fileId: string;
20
+ }
21
+
22
+ /**
23
+ * "Apps with access" (ADR 0043 stage 4c): the applications that can open this
24
+ * file on their own — one that stored it for the person (`upload`), one the
25
+ * person attached it to (`link`) — each with a Revoke action behind a
26
+ * confirmation. Revoking stops the application reading the file and every
27
+ * playback it started; the row leaves the list at once and comes back if the
28
+ * revoke fails. Shown only on the owner's own file.
29
+ */
30
+ export const FileAppAccessSection: React.FC<FileAppAccessSectionProps> = ({ fileId }) => {
31
+ const { t } = useI18n();
32
+ const { colors } = useTheme();
33
+ const { data: apps, isLoading, isError } = useFileAppAccess(fileId);
34
+ const revoke = useRevokeFileAppAccess();
35
+ const [revokingId, setRevokingId] = useState<string | null>(null);
36
+
37
+ const confirmRevoke = useCallback(
38
+ async (app: FileApplicationAccess) => {
39
+ const confirmed = await surfaces.confirm({
40
+ title: t('fileManagement.appAccess.confirm.title', { name: app.name }),
41
+ description: t('fileManagement.appAccess.confirm.message', { name: app.name }),
42
+ confirmLabel: t('fileManagement.appAccess.revoke'),
43
+ cancelLabel: t('common.cancel'),
44
+ destructive: true,
45
+ });
46
+ if (!confirmed) return;
47
+ setRevokingId(app.applicationId);
48
+ try {
49
+ await revoke.mutateAsync({ fileId, applicationId: app.applicationId });
50
+ toast.success(t('fileManagement.appAccess.toasts.revoked', { name: app.name }));
51
+ } catch {
52
+ toast.error(t('fileManagement.appAccess.toasts.revokeFailed', { name: app.name }));
53
+ } finally {
54
+ setRevokingId(null);
55
+ }
56
+ },
57
+ [fileId, revoke, t],
58
+ );
59
+
60
+ const viaText = (app: FileApplicationAccess): string =>
61
+ app.via.map((via) => t(`fileManagement.appAccess.via.${via}`)).join(' · ');
62
+
63
+ return (
64
+ <View className="w-full mb-space-24">
65
+ <Text className="text-text text-[16px] font-semibold mb-space-4">
66
+ {t('fileManagement.appAccess.title')}
67
+ </Text>
68
+ <Text className="text-text-secondary text-[14px] mb-space-12">
69
+ {t('fileManagement.appAccess.description')}
70
+ </Text>
71
+ {isLoading ? (
72
+ <ActivityIndicator color={colors.primary} />
73
+ ) : isError ? (
74
+ <Text className="text-text-secondary text-[14px]">
75
+ {t('fileManagement.appAccess.loadError')}
76
+ </Text>
77
+ ) : !apps || apps.length === 0 ? (
78
+ <Text className="text-text-secondary text-[14px]">
79
+ {t('fileManagement.appAccess.empty')}
80
+ </Text>
81
+ ) : (
82
+ <SettingsListGroup>
83
+ {apps.map((app) => (
84
+ <SettingsListItem
85
+ key={app.applicationId}
86
+ icon={<Avatar source={app.icon} name={app.name} size={APP_ICON_SIZE} />}
87
+ title={app.name}
88
+ description={viaText(app)}
89
+ showChevron={false}
90
+ rightElement={
91
+ <Button
92
+ appearance="subtle"
93
+ tone="danger"
94
+ size="sm"
95
+ loading={revokingId === app.applicationId}
96
+ disabled={revokingId !== null}
97
+ accessibilityLabel={t('fileManagement.appAccess.a11y.revoke', { name: app.name })}
98
+ onPress={() => {
99
+ void confirmRevoke(app);
100
+ }}
101
+ >
102
+ {t('fileManagement.appAccess.revoke')}
103
+ </Button>
104
+ }
105
+ />
106
+ ))}
107
+ </SettingsListGroup>
108
+ )}
109
+ </View>
110
+ );
111
+ };
@@ -9,6 +9,7 @@ import { surfaces, type SurfaceControls } from '@oxy.so/bloom/surfaces';
9
9
  import type { FileMetadata } from '@oxy.so/core';
10
10
  import { useI18n } from '../../hooks/useI18n';
11
11
  import { formatFileSize, getFileIcon } from '../../utils/fileManagement';
12
+ import { FileAppAccessSection } from './FileAppAccessSection';
12
13
 
13
14
  interface FileDetailsModalProps {
14
15
  /** The presenting surface's controls (from `surfaces.present`). */
@@ -21,7 +22,8 @@ interface FileDetailsModalProps {
21
22
 
22
23
  /**
23
24
  * File-detail panel — a rich presented surface (NOT a yes/no confirm): a file's
24
- * icon + metadata plus download / delete / cancel actions. Each action first
25
+ * icon + metadata, for the owner the applications that can open it ("Apps with
26
+ * access", ADR 0043 stage 4c), plus download / delete / cancel actions. Each action first
25
27
  * dismisses this surface, then runs its handler, so a follow-up delete confirm
26
28
  * stacks cleanly above whatever remains. Presented via {@link presentFileDetails}.
27
29
  */
@@ -83,6 +85,8 @@ export const FileDetailsModal: React.FC<FileDetailsModalProps> = ({
83
85
  </View>
84
86
  )}
85
87
  </View>
88
+
89
+ {isOwner ? <FileAppAccessSection fileId={file.id} /> : null}
86
90
  </Card>
87
91
 
88
92
  <View style={{ gap: 8 }}>
@@ -125,6 +125,7 @@ import { useFollow } from '../hooks/useFollow';
125
125
  import { commitDeviceSetAndResolve } from './commitSessionFlow';
126
126
  import { clearSignInFlows } from '../components/signIn/signInFlowStore';
127
127
  import { useOxyAccountGraph } from './useOxyAccountGraph';
128
+ import { AppAssertionProvider } from '../appAssertion/AppAssertionContext';
128
129
 
129
130
  export type { LogoutResult, OxyContextState, OxyRuntimeProviderProps } from './oxyContextTypes';
130
131
 
@@ -1742,7 +1743,18 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1742
1743
  return (
1743
1744
  <OxyRuntimeHandleProvider value={runtime}>
1744
1745
  <OxyAuthActionsContext.Provider value={authActionsValue}>
1745
- <OxyRuntimeContext.Provider value={contextValue}>{children}</OxyRuntimeContext.Provider>
1746
+ <OxyRuntimeContext.Provider value={contextValue}>
1747
+ <AppAssertionProvider
1748
+ oxyServices={oxyServices}
1749
+ clientId={clientId}
1750
+ userId={canUsePrivateApi ? (user?.id ?? null) : null}
1751
+ sessionId={canUsePrivateApi ? activeSessionId : null}
1752
+ storage={storage}
1753
+ storageKeyPrefix={storageKeyPrefix}
1754
+ >
1755
+ {children}
1756
+ </AppAssertionProvider>
1757
+ </OxyRuntimeContext.Provider>
1746
1758
  </OxyAuthActionsContext.Provider>
1747
1759
  </OxyRuntimeHandleProvider>
1748
1760
  );
@@ -36,4 +36,9 @@ export const mutationKeys = {
36
36
  connectedApps: {
37
37
  revoke: ['mutation', 'connectedApps', 'revoke'] as const,
38
38
  },
39
+
40
+ // A file's applications with access (ADR 0043 stage 4c)
41
+ fileAppAccess: {
42
+ revoke: ['mutation', 'fileAppAccess', 'revoke'] as const,
43
+ },
39
44
  } as const;
@@ -3,6 +3,7 @@ import { authenticatedApiCall } from '@oxy.so/core';
3
3
  import type {
4
4
  AssetMetadata,
5
5
  AssetUploadInput,
6
+ FileApplicationAccessRevocation,
6
7
  NotificationPreferences,
7
8
  PrivacySettings,
8
9
  User,
@@ -21,6 +22,7 @@ import { useOxy } from '../../context/OxyContext';
21
22
  import { toast } from '@oxy.so/bloom/toast';
22
23
  import { useAuthStore } from '../../stores/authStore';
23
24
  import { clearedFieldsFromProfileUpdate, upsertCachedUser } from '../queries/userCache';
25
+ import { removeAppAccessFromCache } from '../queries/useFileQueries';
24
26
 
25
27
  /**
26
28
  * Update user profile with optimistic updates and offline queue support
@@ -708,3 +710,34 @@ export const useUploadFile = () => {
708
710
  },
709
711
  });
710
712
  };
713
+
714
+ /**
715
+ * Take an application's access to one of the person's files away (ADR 0043
716
+ * stage 4c): optimistic — the application leaves the cached list at once and
717
+ * comes back if the revoke fails — then the list is re-read either way. The
718
+ * caller shows its own toasts (it knows the application's name).
719
+ */
720
+ export const useRevokeFileAppAccess = () => {
721
+ const { oxyServices, activeSessionId } = useOxy();
722
+ const queryClient = useQueryClient();
723
+
724
+ return useMutation({
725
+ mutationKey: [...mutationKeys.fileAppAccess.revoke],
726
+ mutationFn: async ({ fileId, applicationId }: { fileId: string; applicationId: string }) =>
727
+ authenticatedApiCall<FileApplicationAccessRevocation>(oxyServices, activeSessionId, () =>
728
+ oxyServices.assets.revokeAppAccess(fileId, applicationId),
729
+ ),
730
+ onMutate: async ({ fileId, applicationId }) => {
731
+ await queryClient.cancelQueries({ queryKey: queryKeys.files.appAccess(fileId) });
732
+ return { previous: removeAppAccessFromCache(queryClient, fileId, applicationId) };
733
+ },
734
+ onError: (_error, { fileId }, context) => {
735
+ if (context?.previous) {
736
+ queryClient.setQueryData(queryKeys.files.appAccess(fileId), context.previous);
737
+ }
738
+ },
739
+ onSettled: (_data, _error, { fileId }) => {
740
+ void queryClient.invalidateQueries({ queryKey: queryKeys.files.appAccess(fileId) });
741
+ },
742
+ });
743
+ };
@@ -115,6 +115,11 @@ export const queryKeys = {
115
115
  list: (ownerId?: string) => [...queryKeys.files.all, 'list', ownerId || 'anonymous'] as const,
116
116
  /** The owner's trash (ADR 0043 D5) — under `files.all`, so one invalidation covers both. */
117
117
  trash: (ownerId?: string) => [...queryKeys.files.all, 'trash', ownerId || 'anonymous'] as const,
118
+ /**
119
+ * The applications that can read one file with their own token (ADR 0043
120
+ * stage 4c) — under `files.all`, so a library invalidation refreshes it too.
121
+ */
122
+ appAccess: (fileId: string) => [...queryKeys.files.all, 'appAccess', fileId] as const,
118
123
  },
119
124
 
120
125
  // Connected apps (OAuth grants the user has authorized)
@@ -9,8 +9,13 @@
9
9
  * infinite scroll, pull-to-refresh, and optimistic upload reconciliation.
10
10
  */
11
11
 
12
- import { useInfiniteQuery, type InfiniteData, type QueryClient } from '@tanstack/react-query';
13
- import type { FileMetadata } from '@oxy.so/core';
12
+ import {
13
+ useInfiniteQuery,
14
+ useQuery,
15
+ type InfiniteData,
16
+ type QueryClient,
17
+ } from '@tanstack/react-query';
18
+ import type { FileApplicationAccess, FileMetadata } from '@oxy.so/core';
14
19
  import { useOxy } from '../../context/OxyContext';
15
20
  import { queryKeys } from './queryKeys';
16
21
 
@@ -185,3 +190,40 @@ export function patchFileMetadataInCache(
185
190
  : data,
186
191
  );
187
192
  }
193
+
194
+ // --- Applications with access (ADR 0043 stage 4c) ---------------------------
195
+
196
+ /**
197
+ * The applications that can open one of the owner's files with their own
198
+ * token: one that stored it for the person, or one the person attached it to.
199
+ * Fetched when `enabled` (the owner's own file); `staleTime: 0` re-reads it
200
+ * every time the details open, since an application can gain access at any
201
+ * time (a new upload on the person's behalf).
202
+ */
203
+ export const useFileAppAccess = (fileId: string, enabled = true) => {
204
+ const { oxyServices } = useOxy();
205
+ return useQuery<FileApplicationAccess[], Error>({
206
+ queryKey: queryKeys.files.appAccess(fileId),
207
+ queryFn: () => oxyServices.assets.listAppAccess(fileId),
208
+ enabled: enabled && fileId.length > 0,
209
+ staleTime: 0,
210
+ refetchOnWindowFocus: false,
211
+ });
212
+ };
213
+
214
+ /** Drop one application from a file's cached access list (the optimistic half of a revoke). */
215
+ export function removeAppAccessFromCache(
216
+ queryClient: QueryClient,
217
+ fileId: string,
218
+ applicationId: string,
219
+ ): FileApplicationAccess[] | undefined {
220
+ const key = queryKeys.files.appAccess(fileId);
221
+ const previous = queryClient.getQueryData<FileApplicationAccess[]>(key);
222
+ if (previous) {
223
+ queryClient.setQueryData<FileApplicationAccess[]>(
224
+ key,
225
+ previous.filter((app) => app.applicationId !== applicationId),
226
+ );
227
+ }
228
+ return previous;
229
+ }
@@ -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
+ }
@@ -323,6 +323,7 @@ export const materialCommunityIconsGlyphMap = {
323
323
  "trophy": 984376,
324
324
  "trophy-outline": 984378,
325
325
  "update": 984752,
326
+ "upload": 984402,
326
327
  "video-outline": 986076,
327
328
  "volume-off": 984449,
328
329
  "wallet": 984452,
package/src/ui/index.ts CHANGED
@@ -37,6 +37,8 @@ export { default as ProfileButton } from './components/ProfileButton';
37
37
 
38
38
  // Context + hooks
39
39
  export { useOxy, useOptionalOxy, OxyProviderMissingError } from './context/OxyContext';
40
+ export { useAppAssertion } from './appAssertion/AppAssertionContext';
41
+ export type { UseAppAssertionResult } from './appAssertion/AppAssertionContext';
40
42
  export { useAuth } from './hooks/useAuth';
41
43
  export type { AuthActions, AuthState, SignInOutcome, UseAuthReturn } from './hooks/useAuth';
42
44
  export { useFollow, useSeedFollowStatuses } from './hooks/useFollow';
@@ -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">
@@ -5,6 +5,7 @@ import { Switch } from '@oxy.so/bloom/switch';
5
5
  import { useTheme } from '@oxy.so/bloom/theme';
6
6
  import type { UserPreferences } from '@oxy.so/core';
7
7
  import { getNativeLanguageName } from '@oxy.so/core';
8
+ import { DisplayCurrencySelector } from '../components/DisplayCurrencySelector';
8
9
  import type { BaseScreenProps } from '../types/navigation';
9
10
  import { SettingsIcon } from '../components/SettingsIcon';
10
11
  import { useI18n } from '../hooks/useI18n';
@@ -158,6 +159,7 @@ const PreferencesScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, navigat
158
159
  </SettingsListGroup>
159
160
 
160
161
  <SettingsListGroup title={t('preferences.sections.region') || 'Region'}>
162
+ <DisplayCurrencySelector />
161
163
  <SettingsListItem
162
164
  icon={<SettingsIcon name="clock-outline" color={bloomTheme.colors.info} />}
163
165
  title={t('preferences.items.timezone.title') || 'Timezone'}