@oxyhq/services 13.1.3 → 13.1.5

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 (32) hide show
  1. package/lib/commonjs/index.js +1 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/ProfileButton.js +129 -14
  4. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/ProfileMenu.js +18 -1
  6. package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -1
  7. package/lib/commonjs/ui/index.js.map +1 -1
  8. package/lib/module/index.js +1 -0
  9. package/lib/module/index.js.map +1 -1
  10. package/lib/module/ui/components/ProfileButton.js +129 -14
  11. package/lib/module/ui/components/ProfileButton.js.map +1 -1
  12. package/lib/module/ui/components/ProfileMenu.js +18 -1
  13. package/lib/module/ui/components/ProfileMenu.js.map +1 -1
  14. package/lib/module/ui/index.js +1 -0
  15. package/lib/module/ui/index.js.map +1 -1
  16. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  17. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
  18. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +6 -8
  19. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  21. package/lib/typescript/module/index.d.ts.map +1 -1
  22. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
  23. package/lib/typescript/module/ui/components/ProfileMenu.d.ts +6 -8
  24. package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -1
  25. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  26. package/lib/typescript/types/react-native-web-style.d.ts +27 -0
  27. package/package.json +1 -1
  28. package/src/index.ts +1 -0
  29. package/src/types/react-native-web-style.d.ts +27 -0
  30. package/src/ui/components/ProfileButton.tsx +138 -14
  31. package/src/ui/components/ProfileMenu.tsx +16 -1
  32. package/src/ui/index.ts +1 -0
@@ -28,6 +28,33 @@ const MENU_WIDTH = 300;
28
28
  /** Gutter, in px, kept between the popover and the viewport edges on web. */
29
29
  const VIEWPORT_GUTTER = 8;
30
30
 
31
+ /**
32
+ * Web-only Bluesky-style hover animation timings, mirrored from
33
+ * `social-app/src/view/shell/desktop/LeftNav.tsx`. The row background and the
34
+ * name/handle/chevron opacity cross-fade quickly; the avatar shrink+slide is
35
+ * slower and more deliberate. A short delay on the LEAVE transition keeps the
36
+ * row from flickering when the pointer crosses a child boundary.
37
+ */
38
+ const BG_TRANSITION_MS = 150;
39
+ const AVATAR_TRANSITION_MS = 250;
40
+ const OPACITY_TRANSITION_MS = 150;
41
+ const LEAVE_DELAY_MS = 50;
42
+
43
+ /** Shrunk-avatar scale on hover, matching Bluesky's `scale: 2/3`. */
44
+ const ACTIVE_AVATAR_SCALE = 2 / 3;
45
+
46
+ /**
47
+ * Reads the OS "reduce motion" preference on web. Guards `window`/`matchMedia`
48
+ * so it is safe during SSR and on native (where it is never called). When
49
+ * reduced motion is on, the avatar transform snaps instead of transitioning.
50
+ */
51
+ const prefersReducedMotion = (): boolean => {
52
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
53
+ return false;
54
+ }
55
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
56
+ };
57
+
31
58
  export interface ProfileButtonProps {
32
59
  /**
33
60
  * Expanded row (avatar + name + handle + chevron) when `true` (default), or a
@@ -93,6 +120,11 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
93
120
  const [menuOpen, setMenuOpen] = useState(false);
94
121
  const [anchor, setAnchor] = useState<ProfileMenuAnchor | null>(null);
95
122
 
123
+ // Web-only hover/focus tracking for the Bluesky-style reveal animation.
124
+ // Native has no hover, so these stay false and the row renders statically.
125
+ const [hovered, setHovered] = useState(false);
126
+ const [focused, setFocused] = useState(false);
127
+
96
128
  // Trigger ref for the web anchor measurement. RN-Web exposes `measure()` on
97
129
  // host views, so we anchor the popover to the button rect and open upward.
98
130
  const triggerRef = useRef<View | null>(null);
@@ -159,7 +191,7 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
159
191
  const signInLabel = t('common.actions.signIn') || 'Sign in';
160
192
  return (
161
193
  <Pressable
162
- className={`flex-row items-center gap-3 rounded-full px-2 py-2 ${className ?? ''}`}
194
+ className={`${expanded ? 'w-full ' : ''}flex-row items-center gap-3 rounded-full px-2 py-2 ${className ?? ''}`}
163
195
  style={style}
164
196
  onPress={() => { void signIn(); }}
165
197
  accessibilityRole="button"
@@ -201,16 +233,46 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
201
233
  />
202
234
  );
203
235
 
236
+ // `active` combines hover, keyboard focus, and menu-open — exactly like
237
+ // Bluesky's `state.hovered || state.focused || control.isOpen`. Only web
238
+ // ever sets `hovered`/`focused`, so on native this reduces to `menuOpen`.
239
+ const active = hovered || focused || menuOpen;
240
+ const accountLabel = t('accountSwitcher.switchWhileSignedInAs', { name: displayName })
241
+ || `Switch account, signed in as ${displayName}`;
242
+
243
+ // Web-only pointer/focus handlers. RN-Web forwards `onHoverIn`/`onHoverOut`
244
+ // to the underlying element; native Pressable ignores them harmlessly, but
245
+ // we only attach them on web to keep the native path pristine.
246
+ const webInteractionProps = isWeb
247
+ ? {
248
+ onHoverIn: () => setHovered(true),
249
+ onHoverOut: () => setHovered(false),
250
+ onFocus: () => setFocused(true),
251
+ onBlur: () => setFocused(false),
252
+ }
253
+ : undefined;
254
+
204
255
  if (!expanded) {
256
+ // Collapsed: avatar-only. On web, hovering just fades a subtle round
257
+ // background behind the avatar — no shrink, no text.
258
+ const collapsedBgStyle: ViewStyle | undefined = isWeb
259
+ ? {
260
+ backgroundColor: active ? colors.backgroundSecondary : 'transparent',
261
+ transitionProperty: 'background-color',
262
+ transitionDuration: `${BG_TRANSITION_MS}ms`,
263
+ transitionDelay: active ? '0ms' : `${LEAVE_DELAY_MS}ms`,
264
+ }
265
+ : undefined;
205
266
  return (
206
267
  <View className={className} style={style}>
207
268
  <Pressable
208
269
  ref={triggerRef}
209
270
  className="rounded-full"
271
+ style={collapsedBgStyle}
210
272
  onPress={openMenu}
211
273
  accessibilityRole="button"
212
- accessibilityLabel={t('accountSwitcher.switchWhileSignedInAs', { name: displayName })
213
- || `Switch account, signed in as ${displayName}`}
274
+ accessibilityLabel={accountLabel}
275
+ {...webInteractionProps}
214
276
  >
215
277
  {avatarNode}
216
278
  </Pressable>
@@ -227,18 +289,78 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
227
289
  );
228
290
  }
229
291
 
292
+ // ── Expanded row animation ──────────────────────────────────────────────
293
+ // On native everything is statically visible (no hover exists). On web we
294
+ // reproduce Bluesky's reveal: the row fills with a subtle contrast bg, the
295
+ // avatar shrinks + slides left, and the name/handle/chevron fade in. The
296
+ // negative left margin tucks the identity block under the shrunk avatar so
297
+ // it slides out from behind it as the avatar contracts.
298
+ const reducedMotion = isWeb && prefersReducedMotion();
299
+
300
+ // Slide the shrunk avatar left so its visual left edge stays put as it
301
+ // contracts. The avatar loses `size * (1 - scale)` of width; half of that
302
+ // (the left half, since scale is centered) is the offset, plus the row's
303
+ // left padding (px-2 = 8px) so it hugs the row edge like Bluesky's.
304
+ const activeAvatarTranslateX =
305
+ -(resolvedAvatarSize * (1 - ACTIVE_AVATAR_SCALE)) / 2 - 8;
306
+
307
+ const rowStyle: StyleProp<ViewStyle> = isWeb
308
+ ? {
309
+ backgroundColor: active ? colors.backgroundSecondary : 'transparent',
310
+ transitionProperty: 'background-color',
311
+ transitionDuration: `${BG_TRANSITION_MS}ms`,
312
+ transitionDelay: active ? '0ms' : `${LEAVE_DELAY_MS}ms`,
313
+ }
314
+ : undefined;
315
+
316
+ const avatarWrapperStyle: ViewStyle | undefined = isWeb
317
+ ? {
318
+ zIndex: 10,
319
+ ...(reducedMotion
320
+ ? {}
321
+ : {
322
+ transitionProperty: 'transform',
323
+ transitionDuration: `${AVATAR_TRANSITION_MS}ms`,
324
+ transitionDelay: active ? '0ms' : `${LEAVE_DELAY_MS}ms`,
325
+ }),
326
+ transform: active
327
+ ? [{ scale: ACTIVE_AVATAR_SCALE }, { translateX: activeAvatarTranslateX }]
328
+ : [{ scale: 1 }, { translateX: 0 }],
329
+ }
330
+ : undefined;
331
+
332
+ const identityStyle: ViewStyle | undefined = isWeb
333
+ ? {
334
+ marginLeft: -resolvedAvatarSize / 2,
335
+ opacity: active ? 1 : 0,
336
+ transitionProperty: 'opacity',
337
+ transitionDuration: `${OPACITY_TRANSITION_MS}ms`,
338
+ transitionDelay: active ? '0ms' : `${LEAVE_DELAY_MS}ms`,
339
+ }
340
+ : undefined;
341
+
342
+ const chevronStyle: ViewStyle | undefined = isWeb
343
+ ? {
344
+ opacity: active ? 1 : 0,
345
+ transitionProperty: 'opacity',
346
+ transitionDuration: `${OPACITY_TRANSITION_MS}ms`,
347
+ transitionDelay: active ? '0ms' : `${LEAVE_DELAY_MS}ms`,
348
+ }
349
+ : undefined;
350
+
230
351
  return (
231
- <View className={className} style={style}>
352
+ <View className={`w-full ${className ?? ''}`} style={style}>
232
353
  <Pressable
233
354
  ref={triggerRef}
234
- className="flex-row items-center gap-3 rounded-full px-2 py-2"
355
+ className="w-full flex-row items-center gap-3 rounded-full px-2 py-2"
356
+ style={rowStyle}
235
357
  onPress={openMenu}
236
358
  accessibilityRole="button"
237
- accessibilityLabel={t('accountSwitcher.switchWhileSignedInAs', { name: displayName })
238
- || `Switch account, signed in as ${displayName}`}
359
+ accessibilityLabel={accountLabel}
360
+ {...webInteractionProps}
239
361
  >
240
- {avatarNode}
241
- <View className="min-w-0 flex-1">
362
+ <View style={avatarWrapperStyle}>{avatarNode}</View>
363
+ <View className="min-w-0 flex-1" style={identityStyle}>
242
364
  <Text className="font-bold text-foreground" numberOfLines={1}>
243
365
  {displayName}
244
366
  </Text>
@@ -248,11 +370,13 @@ const ProfileButton: React.FC<ProfileButtonProps> = ({
248
370
  </Text>
249
371
  ) : null}
250
372
  </View>
251
- <MaterialCommunityIcons
252
- name="unfold-more-horizontal"
253
- size={18}
254
- color={colors.textSecondary}
255
- />
373
+ <View style={chevronStyle}>
374
+ <MaterialCommunityIcons
375
+ name="unfold-more-horizontal"
376
+ size={18}
377
+ color={colors.textSecondary}
378
+ />
379
+ </View>
256
380
  </Pressable>
257
381
  <ProfileMenu
258
382
  open={menuOpen}
@@ -62,7 +62,7 @@ export interface ProfileMenuProps {
62
62
  * Renders as an upward-opening popover anchored to the trigger on web, and a
63
63
  * bottom-sheet style modal on native.
64
64
  */
65
- const ProfileMenu: React.FC<ProfileMenuProps> = ({
65
+ const ProfileMenuBody: React.FC<ProfileMenuProps> = ({
66
66
  open,
67
67
  onClose,
68
68
  anchor,
@@ -413,4 +413,19 @@ const styles = {
413
413
  } satisfies ViewStyle,
414
414
  };
415
415
 
416
+ /**
417
+ * Public wrapper: renders nothing — and runs NO hooks — until `open`. Mounting
418
+ * the body only while open keeps the account hooks (`useDeviceAccounts` /
419
+ * refresh-all, Bloom dialog/toast controls) off the render path when the menu is
420
+ * closed, which is every time the sidebar or native drawer mounts
421
+ * `ProfileButton`. A hook that is unsafe on a given platform (e.g. throws during
422
+ * a native render) therefore never runs unless the user actually opens the menu.
423
+ */
424
+ const ProfileMenu: React.FC<ProfileMenuProps> = (props) => {
425
+ if (!props.open) {
426
+ return null;
427
+ }
428
+ return <ProfileMenuBody {...props} />;
429
+ };
430
+
416
431
  export default ProfileMenu;
package/src/ui/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  /// <reference path="../types/react-native-classname.d.ts" />
16
+ /// <reference path="../types/react-native-web-style.d.ts" />
16
17
 
17
18
  // Components
18
19
  export { default as OxyProvider } from './components/OxyProvider';