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() {
void
onLogoutClick: () => void
+ /** Titlebar (mobile): help lives here so the relay strip has more room. */
+ includeHelp?: boolean
}) {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { openBrowseCache } = useCacheBrowser()
+ const { openHelp } = useKeyboardShortcutsHelp()
return (
<>
+ {includeHelp ? (
+ <>
+ {
+ openHelp()
+ }}
+ >
+
+ {t('help.title')}
+
+
+ >
+ ) : null}
navigate('profile')}>
{t('Profile')}
@@ -120,7 +137,11 @@ function SidebarAccountMenu({
-
+
)
@@ -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 ? (
+ openHelp()}
+ title={t('help.title')}
+ aria-label={t('help.title')}
+ >
+
+
+ ) : 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) => (
- {
- e.preventDefault()
- setSlide(i)
- }}
- />
- ))}
-
+ placement === 'mobile' ? (
+
+ {items.map((item, i) => (
+
+ ))}
+
+ ) : (
+
+ {items.map((item, i) => (
+ {
+ e.preventDefault()
+ setSlide(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',