@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.
- package/lib/commonjs/ui/components/FollowButton.js +26 -1
- package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
- package/lib/commonjs/ui/components/FollowTargetButton.js +6 -1
- package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
- package/lib/commonjs/ui/components/followButtonAccessibility.js +72 -0
- package/lib/commonjs/ui/components/followButtonAccessibility.js.map +1 -0
- package/lib/commonjs/ui/components/modals/DeleteAccountModal.js +6 -11
- package/lib/commonjs/ui/components/modals/DeleteAccountModal.js.map +1 -1
- package/lib/commonjs/ui/screens/ManageAccountScreen.js +17 -2
- package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/accountDeletionHandoff.js +99 -0
- package/lib/commonjs/ui/utils/accountDeletionHandoff.js.map +1 -0
- package/lib/module/ui/components/FollowButton.js +26 -1
- package/lib/module/ui/components/FollowButton.js.map +1 -1
- package/lib/module/ui/components/FollowTargetButton.js +6 -1
- package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
- package/lib/module/ui/components/followButtonAccessibility.js +66 -0
- package/lib/module/ui/components/followButtonAccessibility.js.map +1 -0
- package/lib/module/ui/components/modals/DeleteAccountModal.js +6 -11
- package/lib/module/ui/components/modals/DeleteAccountModal.js.map +1 -1
- package/lib/module/ui/screens/ManageAccountScreen.js +17 -2
- package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/module/ui/utils/accountDeletionHandoff.js +95 -0
- package/lib/module/ui/utils/accountDeletionHandoff.js.map +1 -0
- package/lib/typescript/commonjs/ui/components/FollowButton.d.ts +6 -0
- package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/followButtonAccessibility.d.ts +48 -0
- package/lib/typescript/commonjs/ui/components/followButtonAccessibility.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/modals/DeleteAccountModal.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/accountDeletionHandoff.d.ts +35 -0
- package/lib/typescript/commonjs/ui/utils/accountDeletionHandoff.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/FollowButton.d.ts +6 -0
- package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/followButtonAccessibility.d.ts +48 -0
- package/lib/typescript/module/ui/components/followButtonAccessibility.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/modals/DeleteAccountModal.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/accountDeletionHandoff.d.ts +35 -0
- package/lib/typescript/module/ui/utils/accountDeletionHandoff.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/ui/components/FollowButton.tsx +25 -0
- package/src/ui/components/FollowTargetButton.tsx +4 -0
- package/src/ui/components/__tests__/followButtonAccessibility.test.tsx +191 -0
- package/src/ui/components/followButtonAccessibility.ts +83 -0
- package/src/ui/components/modals/DeleteAccountModal.tsx +5 -9
- package/src/ui/screens/ManageAccountScreen.tsx +16 -1
- 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
|
-
|
|
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
|
+
}
|