@oxyhq/services 13.0.3 → 13.1.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 (61) hide show
  1. package/lib/commonjs/index.js +18 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/ProfileButton.js +233 -0
  4. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -0
  5. package/lib/commonjs/ui/components/ProfileMenu.js +374 -0
  6. package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -0
  7. package/lib/commonjs/ui/context/OxyContext.js +99 -18
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +28 -1
  10. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  11. package/lib/commonjs/ui/index.js +14 -0
  12. package/lib/commonjs/ui/index.js.map +1 -1
  13. package/lib/commonjs/ui/utils/activeAuthuser.js +76 -8
  14. package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
  15. package/lib/module/index.js +5 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/ProfileButton.js +228 -0
  18. package/lib/module/ui/components/ProfileButton.js.map +1 -0
  19. package/lib/module/ui/components/ProfileMenu.js +370 -0
  20. package/lib/module/ui/components/ProfileMenu.js.map +1 -0
  21. package/lib/module/ui/context/OxyContext.js +100 -19
  22. package/lib/module/ui/context/OxyContext.js.map +1 -1
  23. package/lib/module/ui/context/hooks/useAuthOperations.js +29 -2
  24. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  25. package/lib/module/ui/index.js +2 -0
  26. package/lib/module/ui/index.js.map +1 -1
  27. package/lib/module/ui/utils/activeAuthuser.js +74 -9
  28. package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +4 -0
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts +46 -0
  32. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +39 -0
  34. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/index.d.ts +2 -0
  38. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +24 -0
  40. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
  41. package/lib/typescript/module/index.d.ts +4 -0
  42. package/lib/typescript/module/index.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/components/ProfileButton.d.ts +46 -0
  44. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -0
  45. package/lib/typescript/module/ui/components/ProfileMenu.d.ts +39 -0
  46. package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -0
  47. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/index.d.ts +2 -0
  50. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +24 -0
  52. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
  53. package/package.json +3 -3
  54. package/src/index.ts +8 -0
  55. package/src/ui/components/ProfileButton.tsx +270 -0
  56. package/src/ui/components/ProfileMenu.tsx +416 -0
  57. package/src/ui/context/OxyContext.tsx +100 -18
  58. package/src/ui/context/hooks/useAuthOperations.ts +35 -2
  59. package/src/ui/index.ts +2 -0
  60. package/src/ui/utils/__tests__/activeAuthuser.test.ts +75 -0
  61. package/src/ui/utils/activeAuthuser.ts +74 -8
package/src/index.ts CHANGED
@@ -211,6 +211,14 @@ export type { AccountMenuProps, AccountMenuAnchor } from './ui/components/Accoun
211
211
  export { default as AccountMenuButton } from './ui/components/AccountMenuButton';
212
212
  export type { AccountMenuButtonProps } from './ui/components/AccountMenuButton';
213
213
 
214
+ // Sidebar account button + its device-account switcher menu (Bluesky-style
215
+ // ProfileCard). `ProfileButton` owns its own open state + anchor measurement
216
+ // and opens `ProfileMenu` upward from a sidebar footer.
217
+ export { default as ProfileButton } from './ui/components/ProfileButton';
218
+ export type { ProfileButtonProps } from './ui/components/ProfileButton';
219
+ export { default as ProfileMenu } from './ui/components/ProfileMenu';
220
+ export type { ProfileMenuProps, ProfileMenuAnchor } from './ui/components/ProfileMenu';
221
+
214
222
  // Unified account switcher (device sign-ins + account graph). `AccountMenuButton`
215
223
  // opens this; the `AccountSwitcherView` body also backs the `AccountSwitcher`
216
224
  // bottom-sheet route.
@@ -0,0 +1,270 @@
1
+ import type React from 'react';
2
+ import { useCallback, useRef, useState } from 'react';
3
+ import {
4
+ View,
5
+ Pressable,
6
+ Platform,
7
+ type StyleProp,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+ import { MaterialCommunityIcons } from '@expo/vector-icons';
11
+ import { Avatar } from '@oxyhq/bloom/avatar';
12
+ import { Text } from '@oxyhq/bloom/typography';
13
+ import { useTheme } from '@oxyhq/bloom/theme';
14
+ import { getAccountDisplayName, getAccountFallbackHandle } from '@oxyhq/core';
15
+ import { useAuth } from '../hooks/useAuth';
16
+ import { useI18n } from '../hooks/useI18n';
17
+ import ProfileMenu, { type ProfileMenuAnchor } from './ProfileMenu';
18
+
19
+ const isWeb = Platform.OS === 'web';
20
+
21
+ /**
22
+ * Fixed popover width used by {@link ProfileMenu} on web. `ProfileButton`
23
+ * mirrors this constant when computing the trigger-relative anchor so the
24
+ * left-edge clamp keeps the panel fully on-screen. Keep the two in sync.
25
+ */
26
+ const MENU_WIDTH = 300;
27
+
28
+ /** Gutter, in px, kept between the popover and the viewport edges on web. */
29
+ const VIEWPORT_GUTTER = 8;
30
+
31
+ export interface ProfileButtonProps {
32
+ /**
33
+ * Expanded row (avatar + name + handle + chevron) when `true` (default), or a
34
+ * bare avatar-only trigger when `false` (collapsed sidebar).
35
+ */
36
+ expanded?: boolean;
37
+ /**
38
+ * Avatar diameter in px. Defaults to 40 when expanded, 32 when collapsed.
39
+ */
40
+ avatarSize?: number;
41
+ /** Navigate to the "Manage account" surface (settings). */
42
+ onNavigateManage: () => void;
43
+ /** Start the add-account / sign-in flow for an additional account. */
44
+ onAddAccount: () => void;
45
+ /** Optional: navigate to the signed-in user's own profile. */
46
+ onNavigateProfile?: () => void;
47
+ /** Called before the active identity changes so apps can clear scoped state. */
48
+ onBeforeSessionChange?: () => void | Promise<void>;
49
+ /** Extra className applied to the outer trigger. */
50
+ className?: string;
51
+ /** Extra style applied to the outer trigger. */
52
+ style?: StyleProp<ViewStyle>;
53
+ }
54
+
55
+ /**
56
+ * Self-contained sidebar account trigger, modeled on Bluesky's ProfileCard and
57
+ * the Oxy inbox's `MailboxDrawer` footer. Owns its own open state + anchor
58
+ * measurement, and renders {@link ProfileMenu} (the device-account switcher).
59
+ *
60
+ * Three auth states from {@link useAuth}:
61
+ * - **Undetermined** (`!isAuthResolved || isPrivateApiPending`): a neutral
62
+ * avatar-sized skeleton circle, no press.
63
+ * - **Signed in**: a pressable row — Bloom `Avatar` + display name + `@handle`
64
+ * + an `unfold-more-horizontal` chevron. Press opens the menu upward (web)
65
+ * against the measured trigger, or as a bottom sheet (native).
66
+ * - **Signed out**: a "Sign in" row that calls `useAuth().signIn()`.
67
+ *
68
+ * All display strings resolve through `@oxyhq/core`'s
69
+ * `getAccountDisplayName` / `getAccountFallbackHandle` — no hand-rolled name
70
+ * fallbacks. Avatars pass the bare file id as `source` + `variant="thumb"`, so
71
+ * the app's registered `ImageResolver` builds the thumbnail URL.
72
+ */
73
+ const ProfileButton: React.FC<ProfileButtonProps> = ({
74
+ expanded = true,
75
+ avatarSize,
76
+ onNavigateManage,
77
+ onAddAccount,
78
+ onNavigateProfile,
79
+ onBeforeSessionChange,
80
+ className,
81
+ style,
82
+ }) => {
83
+ const {
84
+ user,
85
+ isAuthenticated,
86
+ isAuthResolved,
87
+ isPrivateApiPending,
88
+ signIn,
89
+ } = useAuth();
90
+ const { colors } = useTheme();
91
+ const { t, locale } = useI18n();
92
+
93
+ const [menuOpen, setMenuOpen] = useState(false);
94
+ const [anchor, setAnchor] = useState<ProfileMenuAnchor | null>(null);
95
+
96
+ // Trigger ref for the web anchor measurement. RN-Web exposes `measure()` on
97
+ // host views, so we anchor the popover to the button rect and open upward.
98
+ const triggerRef = useRef<View | null>(null);
99
+
100
+ const resolvedAvatarSize = avatarSize ?? (expanded ? 40 : 32);
101
+
102
+ const openMenu = useCallback(() => {
103
+ if (!isWeb) {
104
+ setAnchor(null);
105
+ setMenuOpen(true);
106
+ return;
107
+ }
108
+ const node = triggerRef.current;
109
+ if (!node || typeof node.measure !== 'function' || typeof window === 'undefined') {
110
+ setAnchor(null);
111
+ setMenuOpen(true);
112
+ return;
113
+ }
114
+ node.measure((_x, _y, _w, _h, pageX, pageY) => {
115
+ if (typeof pageX !== 'number' || typeof pageY !== 'number') {
116
+ setAnchor(null);
117
+ } else {
118
+ // Clamp the panel's left edge inside the viewport gutter.
119
+ const maxLeft = Math.max(
120
+ VIEWPORT_GUTTER,
121
+ window.innerWidth - MENU_WIDTH - VIEWPORT_GUTTER,
122
+ );
123
+ const left = Math.min(Math.max(VIEWPORT_GUTTER, pageX), maxLeft);
124
+ // Anchor the panel's BOTTOM edge just above the trigger top so
125
+ // the menu opens upward from this footer button.
126
+ const bottom = Math.max(
127
+ VIEWPORT_GUTTER,
128
+ window.innerHeight - pageY + VIEWPORT_GUTTER,
129
+ );
130
+ setAnchor({ left, bottom });
131
+ }
132
+ setMenuOpen(true);
133
+ });
134
+ }, []);
135
+
136
+ const handleClose = useCallback(() => {
137
+ setMenuOpen(false);
138
+ }, []);
139
+
140
+ // ── Undetermined: skeleton circle, no interaction. ──────────────────────
141
+ if (!isAuthResolved || isPrivateApiPending) {
142
+ return (
143
+ <View
144
+ className={className}
145
+ style={style}
146
+ accessibilityElementsHidden
147
+ importantForAccessibility="no-hide-descendants"
148
+ >
149
+ <View
150
+ className="rounded-full bg-secondary"
151
+ style={{ width: resolvedAvatarSize, height: resolvedAvatarSize }}
152
+ />
153
+ </View>
154
+ );
155
+ }
156
+
157
+ // ── Signed out: "Sign in" row. ──────────────────────────────────────────
158
+ if (!isAuthenticated || !user) {
159
+ const signInLabel = t('common.actions.signIn') || 'Sign in';
160
+ return (
161
+ <Pressable
162
+ className={`flex-row items-center gap-3 rounded-full px-2 py-2 ${className ?? ''}`}
163
+ style={style}
164
+ onPress={() => { void signIn(); }}
165
+ accessibilityRole="button"
166
+ accessibilityLabel={signInLabel}
167
+ >
168
+ <View
169
+ className="items-center justify-center rounded-full bg-secondary"
170
+ style={{ width: resolvedAvatarSize, height: resolvedAvatarSize }}
171
+ >
172
+ <MaterialCommunityIcons
173
+ name="login"
174
+ size={Math.round(resolvedAvatarSize * 0.5)}
175
+ color={colors.icon}
176
+ />
177
+ </View>
178
+ {expanded ? (
179
+ <Text
180
+ className="flex-1 font-semibold text-foreground"
181
+ numberOfLines={1}
182
+ >
183
+ {signInLabel}
184
+ </Text>
185
+ ) : null}
186
+ </Pressable>
187
+ );
188
+ }
189
+
190
+ // ── Signed in: avatar + identity + chevron. ─────────────────────────────
191
+ const displayName = getAccountDisplayName(user, locale);
192
+ const handle = getAccountFallbackHandle(user);
193
+ const handleLine = handle ? `@${handle}` : null;
194
+
195
+ const avatarNode = (
196
+ <Avatar
197
+ source={user.avatar ?? undefined}
198
+ variant="thumb"
199
+ name={displayName}
200
+ size={resolvedAvatarSize}
201
+ />
202
+ );
203
+
204
+ if (!expanded) {
205
+ return (
206
+ <View className={className} style={style}>
207
+ <Pressable
208
+ ref={triggerRef}
209
+ className="rounded-full"
210
+ onPress={openMenu}
211
+ accessibilityRole="button"
212
+ accessibilityLabel={t('accountSwitcher.switchWhileSignedInAs', { name: displayName })
213
+ || `Switch account, signed in as ${displayName}`}
214
+ >
215
+ {avatarNode}
216
+ </Pressable>
217
+ <ProfileMenu
218
+ open={menuOpen}
219
+ onClose={handleClose}
220
+ anchor={anchor}
221
+ onNavigateManage={onNavigateManage}
222
+ onAddAccount={onAddAccount}
223
+ onNavigateProfile={onNavigateProfile}
224
+ onBeforeSessionChange={onBeforeSessionChange}
225
+ />
226
+ </View>
227
+ );
228
+ }
229
+
230
+ return (
231
+ <View className={className} style={style}>
232
+ <Pressable
233
+ ref={triggerRef}
234
+ className="flex-row items-center gap-3 rounded-full px-2 py-2"
235
+ onPress={openMenu}
236
+ accessibilityRole="button"
237
+ accessibilityLabel={t('accountSwitcher.switchWhileSignedInAs', { name: displayName })
238
+ || `Switch account, signed in as ${displayName}`}
239
+ >
240
+ {avatarNode}
241
+ <View className="min-w-0 flex-1">
242
+ <Text className="font-bold text-foreground" numberOfLines={1}>
243
+ {displayName}
244
+ </Text>
245
+ {handleLine ? (
246
+ <Text className="text-xs text-muted-foreground" numberOfLines={1}>
247
+ {handleLine}
248
+ </Text>
249
+ ) : null}
250
+ </View>
251
+ <MaterialCommunityIcons
252
+ name="unfold-more-horizontal"
253
+ size={18}
254
+ color={colors.textSecondary}
255
+ />
256
+ </Pressable>
257
+ <ProfileMenu
258
+ open={menuOpen}
259
+ onClose={handleClose}
260
+ anchor={anchor}
261
+ onNavigateManage={onNavigateManage}
262
+ onAddAccount={onAddAccount}
263
+ onNavigateProfile={onNavigateProfile}
264
+ onBeforeSessionChange={onBeforeSessionChange}
265
+ />
266
+ </View>
267
+ );
268
+ };
269
+
270
+ export default ProfileButton;