@oxy.so/services 4.0.5 → 4.0.7

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 (50) hide show
  1. package/lib/commonjs/ui/components/FollowButton.js +26 -1
  2. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowTargetButton.js +6 -1
  4. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/followButtonAccessibility.js +72 -0
  6. package/lib/commonjs/ui/components/followButtonAccessibility.js.map +1 -0
  7. package/lib/commonjs/ui/components/modals/DeleteAccountModal.js +6 -11
  8. package/lib/commonjs/ui/components/modals/DeleteAccountModal.js.map +1 -1
  9. package/lib/commonjs/ui/screens/ManageAccountScreen.js +17 -2
  10. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  11. package/lib/commonjs/ui/utils/accountDeletionHandoff.js +99 -0
  12. package/lib/commonjs/ui/utils/accountDeletionHandoff.js.map +1 -0
  13. package/lib/module/ui/components/FollowButton.js +26 -1
  14. package/lib/module/ui/components/FollowButton.js.map +1 -1
  15. package/lib/module/ui/components/FollowTargetButton.js +6 -1
  16. package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
  17. package/lib/module/ui/components/followButtonAccessibility.js +66 -0
  18. package/lib/module/ui/components/followButtonAccessibility.js.map +1 -0
  19. package/lib/module/ui/components/modals/DeleteAccountModal.js +6 -11
  20. package/lib/module/ui/components/modals/DeleteAccountModal.js.map +1 -1
  21. package/lib/module/ui/screens/ManageAccountScreen.js +17 -2
  22. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  23. package/lib/module/ui/utils/accountDeletionHandoff.js +95 -0
  24. package/lib/module/ui/utils/accountDeletionHandoff.js.map +1 -0
  25. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts +6 -0
  26. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/ui/components/followButtonAccessibility.d.ts +48 -0
  29. package/lib/typescript/commonjs/ui/components/followButtonAccessibility.d.ts.map +1 -0
  30. package/lib/typescript/commonjs/ui/components/modals/DeleteAccountModal.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/utils/accountDeletionHandoff.d.ts +35 -0
  33. package/lib/typescript/commonjs/ui/utils/accountDeletionHandoff.d.ts.map +1 -0
  34. package/lib/typescript/module/ui/components/FollowButton.d.ts +6 -0
  35. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  36. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
  37. package/lib/typescript/module/ui/components/followButtonAccessibility.d.ts +48 -0
  38. package/lib/typescript/module/ui/components/followButtonAccessibility.d.ts.map +1 -0
  39. package/lib/typescript/module/ui/components/modals/DeleteAccountModal.d.ts.map +1 -1
  40. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  41. package/lib/typescript/module/ui/utils/accountDeletionHandoff.d.ts +35 -0
  42. package/lib/typescript/module/ui/utils/accountDeletionHandoff.d.ts.map +1 -0
  43. package/package.json +4 -4
  44. package/src/ui/components/FollowButton.tsx +25 -0
  45. package/src/ui/components/FollowTargetButton.tsx +4 -0
  46. package/src/ui/components/__tests__/followButtonAccessibility.test.tsx +191 -0
  47. package/src/ui/components/followButtonAccessibility.ts +83 -0
  48. package/src/ui/components/modals/DeleteAccountModal.tsx +5 -9
  49. package/src/ui/screens/ManageAccountScreen.tsx +16 -1
  50. package/src/ui/utils/accountDeletionHandoff.ts +115 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What a screen reader hears for a follow button.
3
+ *
4
+ * Bloom's `FollowButton` names itself "Follow" in both states and carries the
5
+ * state in its pressed flag. Android TalkBack reads that flag as "selected",
6
+ * so a button that SHOWS "Following" was announced as "Follow, selected", and a
7
+ * TalkBack user could not tell they already follow the account
8
+ * (OxyHQ/oxy#1375 item 22). The name now says the state, and who it is for
9
+ * when the caller knows the handle.
10
+ *
11
+ * Pure, and kept apart from the components, so every state is pinned by a
12
+ * table test rather than by a render that can only see one state at a time.
13
+ */
14
+
15
+ export interface FollowButtonAccessibility {
16
+ accessibilityLabel: string;
17
+ accessibilityHint?: string;
18
+ }
19
+
20
+ /** `@nate` from `nate` or `@nate`; `undefined` when there is no handle. */
21
+ export function formatHandle(username: string | undefined): string | undefined {
22
+ const bare = username?.trim().replace(/^@+/, '');
23
+ return bare ? `@${bare}` : undefined;
24
+ }
25
+
26
+ /**
27
+ * The single-user button.
28
+ *
29
+ * - `isKnown: false`: the status is still being fetched and the button is
30
+ * inert, so it says so rather than offering a "Follow" it would ignore.
31
+ * - `isPending`: a follow or unfollow is in flight. The name keeps the state
32
+ * the button still shows; the busy flag says the rest, and there is no hint
33
+ * because a press does nothing until the write settles.
34
+ * - Following: "Following @nate", hinted "Unfollows @nate", because pressing
35
+ * it unfollows, which the visible "Following" does not say.
36
+ */
37
+ export function describeFollowButton(input: {
38
+ isKnown: boolean;
39
+ isFollowing: boolean;
40
+ isPending: boolean;
41
+ username?: string;
42
+ }): FollowButtonAccessibility {
43
+ const handle = formatHandle(input.username);
44
+
45
+ if (!input.isKnown) {
46
+ return {
47
+ accessibilityLabel: handle
48
+ ? `Checking whether you follow ${handle}`
49
+ : 'Checking follow status',
50
+ };
51
+ }
52
+
53
+ const accessibilityLabel = input.isFollowing
54
+ ? handle ? `Following ${handle}` : 'Following'
55
+ : handle ? `Follow ${handle}` : 'Follow';
56
+
57
+ if (input.isPending || !input.isFollowing) return { accessibilityLabel };
58
+
59
+ return {
60
+ accessibilityLabel,
61
+ accessibilityHint: handle ? `Unfollows ${handle}` : 'Unfollows this account',
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The "Follow all" button. The labels are the caller's, since a starter pack
67
+ * and a feed's member list word it differently; the hint carries the count.
68
+ */
69
+ export function describeFollowAllButton(input: {
70
+ allFollowing: boolean;
71
+ isPending: boolean;
72
+ count: number;
73
+ followAllLabel: string;
74
+ followedAllLabel: string;
75
+ }): FollowButtonAccessibility {
76
+ const accessibilityLabel = input.allFollowing ? input.followedAllLabel : input.followAllLabel;
77
+ if (input.isPending) return { accessibilityLabel };
78
+ const accounts = input.count === 1 ? '1 account' : `${input.count} accounts`;
79
+ return {
80
+ accessibilityLabel,
81
+ accessibilityHint: input.allFollowing ? `Unfollows all ${accounts}` : `Follows all ${accounts}`,
82
+ };
83
+ }
@@ -4,6 +4,7 @@ import { View, TextInput } from 'react-native';
4
4
  import { Text } from '@oxy.so/bloom/typography';
5
5
  import Ionicons from '../../icons/Ionicons';
6
6
  import { Button } from '@oxy.so/bloom/button';
7
+ import { Admonition } from '@oxy.so/bloom/admonition';
7
8
  import { useTheme } from '@oxy.so/bloom/theme';
8
9
  import { surfaces, type SurfaceControls } from '@oxy.so/bloom/surfaces';
9
10
 
@@ -69,16 +70,11 @@ const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({
69
70
  {t('deleteAccount.warning') || 'This action cannot be undone. Your account and all associated data will be permanently deleted.'}
70
71
  </Text>
71
72
 
72
- {error && (
73
- <View
74
- className="p-3 rounded-lg mb-4"
75
- style={{ backgroundColor: `${theme.colors.error}20` }}
76
- >
77
- <Text className="text-text text-sm text-center" style={{ color: theme.colors.error }}>
78
- {error}
79
- </Text>
73
+ {error ? (
74
+ <View className="mb-4">
75
+ <Admonition type="error">{error}</Admonition>
80
76
  </View>
81
- )}
77
+ ) : null}
82
78
 
83
79
  <View className="mb-4">
84
80
  <Text className="text-text-secondary text-[13px] mb-2">
@@ -26,6 +26,7 @@ import type { BaseScreenProps } from '../types/navigation';
26
26
  import ProfileSummaryCard from '../components/ProfileSummaryCard';
27
27
  import { SettingsIcon } from '../components/SettingsIcon';
28
28
  import { presentDeleteAccount } from '../components/modals/DeleteAccountModal';
29
+ import { runAccountDeletionHandoff } from '../utils/accountDeletionHandoff';
29
30
  import { presentActionSheet } from '../components/surfaces/ActionSheetSurface';
30
31
  import { useOxy } from '../context/OxyContext';
31
32
  import { useI18n } from '../hooks/useI18n';
@@ -95,6 +96,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
95
96
  openAvatarPicker,
96
97
  accounts,
97
98
  openAccountDialog,
99
+ hasIdentity,
98
100
  } = useOxy();
99
101
 
100
102
  const { data: userFromQuery, isLoading: userLoading } = useCurrentUser({
@@ -330,6 +332,19 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
330
332
  });
331
333
  return;
332
334
  }
335
+ // Native: the deletion is signed with the identity key. When this app
336
+ // does not hold it (Commons keeps it on this device, or it lives on
337
+ // another device), hand off to where it can be signed instead of
338
+ // failing inside the confirmation surface.
339
+ const route = await runAccountDeletionHandoff({
340
+ hasIdentity,
341
+ canOpenURL: (url) => Linking.canOpenURL(url),
342
+ openURL: (url) => Linking.openURL(url),
343
+ t,
344
+ });
345
+ if (route !== 'local') {
346
+ return;
347
+ }
333
348
  const deleted = await presentDeleteAccount({
334
349
  username: user.username || '',
335
350
  onDelete: handleConfirmDelete,
@@ -339,7 +354,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
339
354
  await logout();
340
355
  onClose?.();
341
356
  }
342
- }, [user, t, handleConfirmDelete, logout, onClose]);
357
+ }, [user, t, handleConfirmDelete, hasIdentity, logout, onClose]);
343
358
 
344
359
  if (!isAuthenticated) {
345
360
  return (
@@ -0,0 +1,115 @@
1
+ import { surfaces } from '@oxy.so/bloom/surfaces';
2
+ import { toast } from '@oxy.so/bloom/toast';
3
+ import { IDENTITY_WEB_ORIGIN, logger } from '@oxy.so/core';
4
+
5
+ /**
6
+ * Commons' own delete-account screen (`app/(tabs)/(settings)/delete-account.tsx`
7
+ * in packages/commons). Expo Router drops the route groups, so the scheme path
8
+ * `delete-account` resolves to it; `oxycommons://` is the scheme the account
9
+ * dialog already probes and hands sign-in approvals to.
10
+ */
11
+ export const COMMONS_DELETE_ACCOUNT_URL = 'oxycommons://delete-account';
12
+
13
+ /** The scheme root `Linking.canOpenURL` probes to tell whether Commons is installed. */
14
+ export const COMMONS_APP_SCHEME = 'oxycommons://';
15
+
16
+ type Translate = (key: string, vars?: Record<string, string | number>) => string | undefined;
17
+
18
+ export interface AccountDeletionHandoffDeps {
19
+ /** Whether THIS app can read an identity key it could sign the deletion with. */
20
+ hasIdentity: () => Promise<boolean>;
21
+ canOpenURL: (url: string) => Promise<boolean>;
22
+ openURL: (url: string) => Promise<unknown>;
23
+ t: Translate;
24
+ }
25
+
26
+ /**
27
+ * `'local'`: this app holds the identity key, so the caller runs its own
28
+ * delete-account surface. `'handled'`: it does not, and the user has been sent
29
+ * to where the deletion can actually be signed (or told why not).
30
+ */
31
+ export type AccountDeletionHandoffResult = 'local' | 'handled';
32
+
33
+ /**
34
+ * Decide where a native account deletion can happen, before anything is deleted.
35
+ *
36
+ * Deleting an account is signed with the identity private key. When the key
37
+ * lives in Oxy Commons rather than in this app, the API call cannot be made from
38
+ * here, so the deletion is delegated to Commons' own screen when Commons is
39
+ * installed, and explained otherwise. Nothing on the delegated paths calls the
40
+ * deletion API.
41
+ */
42
+ export async function runAccountDeletionHandoff(
43
+ deps: AccountDeletionHandoffDeps,
44
+ ): Promise<AccountDeletionHandoffResult> {
45
+ const { hasIdentity, canOpenURL, openURL, t } = deps;
46
+ const text = (key: string, fallback: string, vars?: Record<string, string>): string => {
47
+ // An older `@oxy.so/core` echoes a key it has no string for; show English then.
48
+ const value = t(key, vars);
49
+ return value && value !== key ? value : fallback;
50
+ };
51
+
52
+ let holdsIdentity: boolean;
53
+ try {
54
+ holdsIdentity = await hasIdentity();
55
+ } catch (error) {
56
+ // A locked or unreadable keystore is not proof the identity is elsewhere.
57
+ logger.warn('Identity read failed before account deletion', { component: 'accountDeletionHandoff' }, error);
58
+ toast.error(
59
+ text(
60
+ 'deleteAccount.handoff.identityUnreadable',
61
+ "Couldn't read this device's identity storage. Unlock your device and try again.",
62
+ ),
63
+ );
64
+ return 'handled';
65
+ }
66
+ if (holdsIdentity) {
67
+ return 'local';
68
+ }
69
+
70
+ let commonsInstalled = false;
71
+ try {
72
+ commonsInstalled = await canOpenURL(COMMONS_APP_SCHEME);
73
+ } catch {
74
+ commonsInstalled = false;
75
+ }
76
+
77
+ if (commonsInstalled) {
78
+ const open = await surfaces.confirm({
79
+ title: text('deleteAccount.handoff.commonsTitle', 'Delete your account in Oxy Commons'),
80
+ description: text(
81
+ 'deleteAccount.handoff.commonsMessage',
82
+ 'Your identity key is kept by Oxy Commons on this device, and deleting your account needs it. Commons will open on Settings > Delete account to finish.',
83
+ ),
84
+ confirmLabel: text('deleteAccount.handoff.openCommons', 'Open Oxy Commons'),
85
+ cancelLabel: text('common.cancel', 'Cancel'),
86
+ });
87
+ if (open) {
88
+ try {
89
+ await openURL(COMMONS_DELETE_ACCOUNT_URL);
90
+ } catch (error) {
91
+ logger.warn('Opening Commons delete-account failed', { component: 'accountDeletionHandoff' }, error);
92
+ toast.error(
93
+ text(
94
+ 'deleteAccount.handoff.commonsOpenFailed',
95
+ "Couldn't open Oxy Commons. Open it yourself and go to Settings > Delete account.",
96
+ ),
97
+ );
98
+ }
99
+ }
100
+ return 'handled';
101
+ }
102
+
103
+ const site = IDENTITY_WEB_ORIGIN.replace(/^https?:\/\//, '');
104
+ await surfaces.confirm({
105
+ title: text('deleteAccount.handoff.elsewhereTitle', 'Delete your account where your identity is'),
106
+ description: text(
107
+ 'deleteAccount.handoff.elsewhereMessage',
108
+ `Deleting your account needs your identity key, and it isn't on this device. Open Oxy Commons on the device that holds your identity and go to Settings > Delete account, or open ${site} in a browser that holds it.`,
109
+ { site },
110
+ ),
111
+ confirmLabel: text('deleteAccount.handoff.gotIt', 'Got it'),
112
+ hideCancel: true,
113
+ });
114
+ return 'handled';
115
+ }