From f7122baae6e93bdd37f0030511799a69bdc915b0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 15 Apr 2026 11:41:01 +0200 Subject: [PATCH] move ? button on mobile make carousel buttons bigger --- .../ActiveRelaysTitlebarButton.tsx | 2 +- src/components/HelpAndAccountMenu.tsx | 57 ++++++-- src/components/LiveActivitiesStrip.tsx | 126 +++++++++++++++--- src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + 5 files changed, 161 insertions(+), 28 deletions(-) diff --git a/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx b/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx index 5962b316..c164b4e7 100644 --- a/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx +++ b/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx @@ -50,7 +50,7 @@ export function ActiveRelaysTitlebarButton() { - + ) @@ -174,23 +195,27 @@ function TitlebarAccountMenu({ - + ) } /** - * Help (?) + account avatar with the same dropdown on sidebar (desktop) and titlebar (mobile). + * Sidebar: help (?) above account. Titlebar (mobile): help is inside the account menu so the relay strip has more room. */ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { const { t } = useTranslation() const { pubkey, checkLogin } = useNostr() + const { openHelp } = useKeyboardShortcutsHelp() const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) - const help = - variant === 'sidebar' ? : + const help = variant === 'sidebar' ? : null let account: ReactNode if (pubkey) { @@ -223,10 +248,26 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun const wrapClass = variant === 'titlebar' ? 'flex shrink-0 items-center gap-1' : 'flex flex-col space-y-2' + /** Logged-out titlebar: keep ? next to login so help stays reachable without opening login. */ + const titlebarHelpWhenLoggedOut = + variant === 'titlebar' && !pubkey ? ( + + ) : null + return ( <>
{help} + {titlebarHelpWhenLoggedOut} {account}
diff --git a/src/components/LiveActivitiesStrip.tsx b/src/components/LiveActivitiesStrip.tsx index c265059f..1b763e28 100644 --- a/src/components/LiveActivitiesStrip.tsx +++ b/src/components/LiveActivitiesStrip.tsx @@ -6,11 +6,15 @@ import { useLiveActivitiesOptional } from '@/providers/useLiveActivities' import { useUserPreferencesOptional } from '@/providers/UserPreferencesProvider' import storage from '@/services/local-storage.service' import { ExternalLink } from 'lucide-react' -import { useCallback, useEffect, useLayoutEffect, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' type TPlacement = 'sidebar' | 'mobile' +const SWIPE_MIN_PX = 44 +const SWIPE_DOMINANCE_RATIO = 1.2 +const SWIPE_NOTE_OPEN_SUPPRESS_MS = 400 + export default function LiveActivitiesStrip({ placement }: { placement: TPlacement }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigation() @@ -48,11 +52,61 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme setSlide((s) => Math.min(s, items.length - 1)) }, [items.length]) + const swipeHintId = useId() + const swipeGrabRef = useRef<{ x: number; y: number; pointerId: number } | null>(null) + const swipeNavAtRef = useRef(0) + + const releasePointerIfCaptured = useCallback((el: HTMLDivElement, pointerId: number) => { + if (typeof el.hasPointerCapture === 'function' && el.hasPointerCapture(pointerId)) { + el.releasePointerCapture(pointerId) + } + }, []) + + const onSwipePointerDown = useCallback( + (e: React.PointerEvent) => { + if (placement !== 'mobile' || items.length <= 1) return + swipeGrabRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId } + e.currentTarget.setPointerCapture(e.pointerId) + }, + [placement, items.length] + ) + + const onSwipePointerUp = useCallback( + (e: React.PointerEvent) => { + if (placement !== 'mobile' || items.length <= 1) return + const grab = swipeGrabRef.current + swipeGrabRef.current = null + releasePointerIfCaptured(e.currentTarget, e.pointerId) + if (!grab || grab.pointerId !== e.pointerId) return + const dx = e.clientX - grab.x + const dy = e.clientY - grab.y + const ax = Math.abs(dx) + const ay = Math.abs(dy) + if (ax < SWIPE_MIN_PX || ax < ay * SWIPE_DOMINANCE_RATIO) return + swipeNavAtRef.current = Date.now() + if (dx < 0) { + setSlide((s) => (s + 1) % items.length) + } else { + setSlide((s) => (s - 1 + items.length) % items.length) + } + }, + [placement, items.length, releasePointerIfCaptured] + ) + + const onSwipePointerCancel = useCallback( + (e: React.PointerEvent) => { + swipeGrabRef.current = null + releasePointerIfCaptured(e.currentTarget, e.pointerId) + }, + [releasePointerIfCaptured] + ) + // `items` can shrink without a new array identity; `slide` may then be out of range until effects run. const displayIndex = items.length === 0 ? 0 : Math.min(slide, items.length - 1) const itemAtSlide = items[displayIndex] const openLiveNote = useCallback(() => { + if (Date.now() - swipeNavAtRef.current < SWIPE_NOTE_OPEN_SUPPRESS_MS) return const ev = itemAtSlide?.event if (!ev) return // Same bech32 as {@link getNoteBech32Id} + pass event so {@link navigationEventStore} / cache match the URL. @@ -68,16 +122,25 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme return null } + const mobileSwipe = placement === 'mobile' && items.length > 1 + return (
+ {mobileSwipe ? ( + + {t('liveActivities.swipeToBrowse')} + + ) : null}
{t('liveActivities.heading')}
@@ -85,8 +148,12 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme className={cn( 'flex min-w-0 gap-1.5 rounded-md', placement === 'sidebar' && 'flex-col xl:flex-row xl:items-stretch', - placement === 'mobile' && 'items-stretch' + placement === 'mobile' && 'items-stretch touch-pan-y', + mobileSwipe && 'cursor-grab active:cursor-grabbing' )} + onPointerDown={mobileSwipe ? onSwipePointerDown : undefined} + onPointerUp={mobileSwipe ? onSwipePointerUp : undefined} + onPointerCancel={mobileSwipe ? onSwipePointerCancel : undefined} >
{items.length > 1 ? ( -
- {items.map((item, i) => ( -
+ placement === 'mobile' ? ( +
+ {items.map((item, i) => ( + + ))} +
+ ) : ( +
+ {items.map((item, i) => ( + + ))} +
+ ) ) : null} ) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 8360f7ef..22ab24c9 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -621,6 +621,8 @@ export default { 'liveActivities.regionLabel': 'Live-Räume und Streams', 'liveActivities.fromFollow': 'Von jemandem, dem du folgst', 'liveActivities.goToSlide': 'Live-Eintrag {{n}} anzeigen', + 'liveActivities.swipeToBrowse': + 'Wische auf dem Banner nach links oder rechts, um zwischen Live-Aktivitäten zu wechseln.', 'liveActivities.viewNoteTitle': 'Diese Live-Aktivität als Beitrag öffnen (Wiedergabe in der App, Links darunter)', 'liveActivities.openJoinPageTitle': diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 41ea3c67..9b466530 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -616,6 +616,8 @@ export default { 'liveActivities.regionLabel': 'Live spaces and streams', 'liveActivities.fromFollow': 'From someone you follow', 'liveActivities.goToSlide': 'Show live item {{n}}', + 'liveActivities.swipeToBrowse': + 'Swipe left or right on the banner to switch between live activities.', 'liveActivities.viewNoteTitle': 'Open this live activity as a note (play in app, links below)', 'liveActivities.openJoinPageTitle': 'Open the join page in a new tab (e.g. zap.stream or the room site)', 'liveActivities.settingsToggle': 'Live activities banner',