diff --git a/package.json b/package.json index d1d19bb..e933903 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jank", - "version": "26.14.2", + "version": "26.15.0", "description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient", "type": "module", "author": "DocNR", diff --git a/src/DeckManager.tsx b/src/DeckManager.tsx index 669ec4a..f51166f 100644 --- a/src/DeckManager.tsx +++ b/src/DeckManager.tsx @@ -15,15 +15,18 @@ * here. */ import { activeColumnIdAtom } from '@/atoms/active-column' +import { mobileNavStackAtom } from '@/atoms/mobile-nav-stack' import AgentDrawer from '@/components/AgentDrawer' import CommandDispatcher from '@/components/CommandDispatcher' import CommandPalette from '@/components/CommandPalette' import StarterCommands from '@/components/CommandPalette/StarterCommands' +import MobileNavStack from '@/components/MobileNavStack' import Shell from '@/components/Shell' import TopBar from '@/components/TopBar' +import { opensAsColumnOnMobile } from '@/lib/link' import DeckHomePage from '@/pages/primary/DeckHomePage' import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider' -import { useStore } from 'jotai' +import { useSetAtom, useStore } from 'jotai' import { createContext, useCallback, @@ -71,6 +74,7 @@ export function DeckManager() { const { isSmallScreen } = useScreenSize() const { addTransientColumn, columns, removeColumn } = useColumns() const { account } = useNostr() + const setMobileStack = useSetAtom(mobileNavStackAtom) const ignorePopStateRef = useRef(false) // Read the active column id imperatively (not via useAtomValue) so this // value can be sampled inside the pushSecondaryPage callback without @@ -136,6 +140,19 @@ export function DeckManager() { return } + // Mobile push-stack: back pops one screen. Re-arm a history entry so the + // NEXT back also fires popstate (instead of exiting the SPA). + const navStack = jotaiStore.get(mobileNavStackAtom) + if (navStack.length > 0) { + setMobileStack(navStack.slice(0, -1)) + try { + window.history.pushState(null, '', '/') + } catch { + /* non-browser env — skip */ + } + return + } + // Back-button closes the focused transient column. There is no // history entry per transient column, so one back-click closes one // transient — no stack to navigate through. Pinned columns are @@ -182,6 +199,20 @@ export function DeckManager() { url: string, opts?: { sourceViewContext?: string; sourceSigningIdentity?: string | null } ) => { + // Mobile: in-feed drill-downs (note threads, profiles, settings, ...) + // become native pushed screens. Only feed-shaped standing surfaces + // (hashtag/relay/search/notifications/bookmarks/mutes) still spawn deck + // columns. Desktop is unchanged — everything spawns a transient column. + if (isSmallScreen && !opensAsColumnOnMobile(url)) { + setMobileStack((prev) => [...prev, { id: crypto.randomUUID(), url }]) + // Arm a history entry so hardware/gesture back pops the screen. + try { + window.history.pushState(null, '', '/') + } catch { + /* non-browser env — skip */ + } + return + } const parentColumnId = jotaiStore.get(activeColumnIdAtom) ?? undefined const source = opts?.sourceViewContext ? { @@ -191,7 +222,7 @@ export function DeckManager() { : null addTransientColumn(url, source, parentColumnId) }, - [addTransientColumn, jotaiStore] + [isSmallScreen, setMobileStack, addTransientColumn, jotaiStore] ) // popSecondaryPage outside a DetailColumnBody = "close the focused @@ -224,6 +255,7 @@ export function DeckManager() { content={} bottomBar={isSmallScreen ? : null} /> + {isSmallScreen && } diff --git a/src/atoms/active-column.ts b/src/atoms/active-column.ts index 6af7350..49b9fd8 100644 --- a/src/atoms/active-column.ts +++ b/src/atoms/active-column.ts @@ -52,6 +52,13 @@ export const focusedColumnRequestAtom = atom(null) */ export const focusBeamActiveAtom = atom(false) +/** + * Open/closed state of the column overview ("exploding tabs") overlay — a + * full-screen grid of every open column for jump / close / add. Opened from the + * mobile page-dot pill's grid button and a desktop TopBar button. Ephemeral. + */ +export const columnOverviewOpenAtom = atom(false) + /** * Open/closed state of the Track B agent chat drawer. Lifted into Jotai so the * TopBar toggle button and the AgentDrawer shell (mounted in DeckManager) share diff --git a/src/atoms/mobile-nav-stack.ts b/src/atoms/mobile-nav-stack.ts new file mode 100644 index 0000000..1868f3b --- /dev/null +++ b/src/atoms/mobile-nav-stack.ts @@ -0,0 +1,17 @@ +import { atom } from 'jotai' + +/** + * Mobile native push/pop navigation stack. Each in-feed drill-down (note + * thread, profile, settings, ...) on mobile appends an entry here and renders + * as a full-screen pushed screen instead of spawning a deck column. Back chevron + * / hardware back / Overview pop or clear it. Desktop never uses this — it keeps + * spawning transient columns. Ephemeral (not persisted). + */ +export type TMobileNavEntry = { + /** Stable key for the layer (also the React key + slide-in animation reset). */ + id: string + /** The pushed URL, dispatched against SECONDARY_ROUTES. */ + url: string +} + +export const mobileNavStackAtom = atom([]) diff --git a/src/components/AddColumnModal/PickerGrid.tsx b/src/components/AddColumnModal/PickerGrid.tsx index ccf5c21..50e86ed 100644 --- a/src/components/AddColumnModal/PickerGrid.tsx +++ b/src/components/AddColumnModal/PickerGrid.tsx @@ -120,7 +120,7 @@ export default function PickerGrid({ onSelect }: Props) { )} > - + {t(desc.label)} {/* Keyboard-shortcut hint in the bottom-end corner. Muted + diff --git a/src/components/BottomNavigationBar/HomeButton.tsx b/src/components/BottomNavigationBar/HomeButton.tsx deleted file mode 100644 index 13e386f..0000000 --- a/src/components/BottomNavigationBar/HomeButton.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { HouseIcon } from '@phosphor-icons/react' -import { useCallback } from 'react' -import BottomNavigationBarItem from './BottomNavigationBarItem' - -export default function HomeButton() { - // Phase 2: only the deck-home primary page exists, and home is the page - // we're rendering into. The button is always "active" — its meaningful - // action is scrolling the horizontal deck back to its first column, - // mirroring the TopBar brand-tap. - // - // The deck-scroller's first column gets the snap directly — using its - // `scrollIntoView` is more reliable inside a snap-mandatory container than - // `scroller.scrollTo({ left: 0 })` (Chrome rejects the latter and snaps - // back to wherever it was). No rAF wrap for the same reason — synchronous - // scrollIntoView lands cleanly. - const onClick = useCallback(() => { - const scroller = document.querySelector('[data-deck-scroll]') - const firstCol = scroller?.querySelector('[data-column-id]') - firstCol?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'instant' }) - }, []) - - return ( - - - - ) -} diff --git a/src/components/BottomNavigationBar/NewColumnButton.tsx b/src/components/BottomNavigationBar/NewColumnButton.tsx new file mode 100644 index 0000000..6d0237e --- /dev/null +++ b/src/components/BottomNavigationBar/NewColumnButton.tsx @@ -0,0 +1,16 @@ +import { addColumnDialogOpenAtom } from '@/atoms/active-column' +import { Plus } from '@phosphor-icons/react' +import { useSetAtom } from 'jotai' +import BottomNavigationBarItem from './BottomNavigationBarItem' + +// Opens the column picker (AddColumnModal, mounted in DeckArea and driven by +// addColumnDialogOpenAtom). The bottom-bar "+" reads as "new column" — compose +// has its own pencil button now. +export default function NewColumnButton() { + const setAddOpen = useSetAtom(addColumnDialogOpenAtom) + return ( + setAddOpen(true)}> + + + ) +} diff --git a/src/components/BottomNavigationBar/OverviewButton.tsx b/src/components/BottomNavigationBar/OverviewButton.tsx new file mode 100644 index 0000000..0219506 --- /dev/null +++ b/src/components/BottomNavigationBar/OverviewButton.tsx @@ -0,0 +1,27 @@ +import { columnOverviewOpenAtom } from '@/atoms/active-column' +import { mobileNavStackAtom } from '@/atoms/mobile-nav-stack' +import { SquaresFour } from '@phosphor-icons/react' +import { useSetAtom } from 'jotai' +import BottomNavigationBarItem from './BottomNavigationBarItem' + +// "Home base" for the deck on mobile: opens the column overview (the exploding +// grid of all open columns). Replaces the old scroll-to-first-column home +// button — the brand tap in the TopBar still does that, and the overview +// reaches any column including the first. +// +// Also the one-tap escape from a deep push-stack: clears any open drill-down +// screens so you land back on the deck overview from any depth. +export default function OverviewButton() { + const setOverviewOpen = useSetAtom(columnOverviewOpenAtom) + const setMobileStack = useSetAtom(mobileNavStackAtom) + return ( + { + setMobileStack([]) + setOverviewOpen(true) + }} + > + + + ) +} diff --git a/src/components/BottomNavigationBar/PostButton.tsx b/src/components/BottomNavigationBar/PostButton.tsx index 1d84b22..86ab37b 100644 --- a/src/components/BottomNavigationBar/PostButton.tsx +++ b/src/components/BottomNavigationBar/PostButton.tsx @@ -1,9 +1,11 @@ import PostEditor from '@/components/PostEditor/LazyPostEditor' import { useNostr } from '@/providers/NostrProvider' -import { PlusCircleIcon } from '@phosphor-icons/react' +import { NotePencil } from '@phosphor-icons/react' import { useState } from 'react' import BottomNavigationBarItem from './BottomNavigationBarItem' +// Compose a note. A pencil now, not a plus — the bottom-bar "+" means "new +// column". Kept as PostButton (it drives the PostEditor) to avoid churn. export default function PostButton() { const { checkLogin } = useNostr() const [open, setOpen] = useState(false) @@ -17,7 +19,7 @@ export default function PostButton() { }) }} > - + diff --git a/src/components/BottomNavigationBar/index.tsx b/src/components/BottomNavigationBar/index.tsx index 1796d37..b74a686 100644 --- a/src/components/BottomNavigationBar/index.tsx +++ b/src/components/BottomNavigationBar/index.tsx @@ -1,11 +1,11 @@ import { cn } from '@/lib/utils' -import HomeButton from './HomeButton' +import NewColumnButton from './NewColumnButton' +import OverviewButton from './OverviewButton' import PostButton from './PostButton' -// WS2 shrunk the bar to Home + Post. Search/Messages/Notifications are deck -// columns now, not primary pages, so the icons no longer have a meaningful -// destination. The middle is reserved for the WS3 swipe page-dot indicator. -// BackgroundAudio moved to the unified shell's floating widget mount. +// Overview (all columns) · New column · Compose. The middle is reserved for the +// WS3 swipe page-dot indicator. Search/Messages/Notifications are deck columns +// now, so they don't get bottom-bar icons. export default function BottomNavigationBar() { return (
- + +
diff --git a/src/components/Column/index.tsx b/src/components/Column/index.tsx index 85618b0..a0d906d 100644 --- a/src/components/Column/index.tsx +++ b/src/components/Column/index.tsx @@ -232,7 +232,10 @@ export function Column({ column, dragHandleProps, style }: Props) { if (!isActive) setActiveColumnId(column.id) }} className={cn( - 'border-border bg-card flex h-full shrink-0 flex-col overflow-hidden rounded-lg border shadow-md transition-[box-shadow,border-color] duration-200', + // Mobile: edge-to-edge, no card frame (one column fills the screen, so + // the border/rounding/shadow that separate side-by-side columns are + // wasted chrome). Desktop keeps the floating-card frame. + 'border-border bg-card flex h-full shrink-0 flex-col overflow-hidden transition-[box-shadow,border-color] duration-200 md:rounded-lg md:border md:shadow-md', // Sticky-selection affordance: recolor the existing 1px border to // the account hue (so the top stripe + side border read as one // continuous frame, no second outline) and pump the layered @@ -321,7 +324,12 @@ export function Column({ column, dragHandleProps, style }: Props) {
| null = null +function iconFor(type: TColumnType): LucideIcon { + if (!iconByType) iconByType = new Map(COLUMN_TYPES.map((d) => [d.type, d.icon])) + return iconByType.get(type) ?? (type === 'dvm-discover' ? Compass : SquareStack) +} + +export default function ColumnOverview() { + const { t } = useTranslation() + const [open, setOpen] = useAtom(columnOverviewOpenAtom) + const { columns, removeColumn } = useColumns() + const activeId = useAtomValue(activeColumnIdAtom) + const setFocusedRequest = useSetAtom(focusedColumnRequestAtom) + const setAddOpen = useSetAtom(addColumnDialogOpenAtom) + + // Escape closes the overlay (desktop affordance; harmless on mobile). + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, setOpen]) + + if (!open) return null + + const jumpTo = (id: string) => { + setFocusedRequest(id) // DeckArea: set active + scroll into view + setOpen(false) + } + + const addColumn = () => { + setOpen(false) + setAddOpen(true) + } + + return ( +
+
+ {t('Columns')} + +
+ +
+
+ {columns.map((c) => ( + jumpTo(c.id)} + onClose={() => removeColumn(c.id)} + /> + ))} + +
+
+
+ ) +} + +function ColumnCard({ + column, + isActive, + onJump, + onClose +}: { + column: TColumn + isActive: boolean + onJump: () => void + onClose: () => void +}) { + const { t } = useTranslation() + const Icon = iconFor(column.type) + const hue = pubkeyToHsl(column.viewContext) + + return ( +
+ + +
+ ) +} diff --git a/src/components/DeckArea/ColumnPageIndicator.tsx b/src/components/DeckArea/ColumnPageIndicator.tsx index 57547ab..45981e9 100644 --- a/src/components/DeckArea/ColumnPageIndicator.tsx +++ b/src/components/DeckArea/ColumnPageIndicator.tsx @@ -2,24 +2,25 @@ import { cn } from '@/lib/utils' import { pubkeyToHsl } from '@/lib/pubkey' import { TColumn } from '@/types/column' -import { Plus } from 'lucide-react' type Props = { columns: TColumn[] activeColumnId: string | null onJumpToColumn: (id: string) => void - onJumpToAddPlaceholder: () => void } /** * WS3 — bottom-anchored page-dot indicator for the swipe-snap deck on mobile. - * One dot per column plus a trailing `+` slot for the add-column placeholder - * page. The active dot tints to the column's account hue so the indicator - * doubles as a "whose perspective am I on?" reference at a glance. + * One dot per column; tap a dot to jump to it. The active dot tints to the + * column's account hue so the indicator doubles as a "whose perspective am I + * on?" reference at a glance. Overview / add-column / compose live in the + * bottom bar, not here. * - * Positioned above the BottomBar via the `--bottom-bar-offset` CSS variable - * the Shell sets on its stacked-layout wrapper. `z-30` sits below the bottom - * bar (`z-40`) and below drawers / modals (`z-50+`). + * Positioned above the BottomBar. The bar is a 3rem (h-12) button row plus + * `env(safe-area-inset-bottom)` (the home-indicator inset), so the pager has + * to offset by both — offsetting by a flat 3rem tucks it behind the bar on + * home-indicator iPhones. `z-30` sits below the bottom bar (`z-40`) and below + * drawers / modals (`z-50+`). * * The buttons are wrapped in larger transparent tap-targets so a thumb can * still hit them despite the visual dot being small. @@ -27,13 +28,12 @@ type Props = { export default function ColumnPageIndicator({ columns, activeColumnId, - onJumpToColumn, - onJumpToAddPlaceholder + onJumpToColumn }: Props) { return (
) })} -
) diff --git a/src/components/DeckArea/index.tsx b/src/components/DeckArea/index.tsx index 4d41165..ec33a3b 100644 --- a/src/components/DeckArea/index.tsx +++ b/src/components/DeckArea/index.tsx @@ -22,6 +22,7 @@ import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useAtom } from 'jotai' import { useCallback, useEffect, useRef, useState } from 'react' import AddColumnModal from '@/components/AddColumnModal' +import ColumnOverview from '@/components/ColumnOverview' import AddColumnPlaceholder from './AddColumnPlaceholder' import ColumnPageIndicator from './ColumnPageIndicator' import EmptyDeckCTA from './EmptyDeckCTA' @@ -48,16 +49,13 @@ export default function DeckArea() { const { isSmallScreen } = useScreenSize() const scrollerRef = useRef(null) const prevColumnsRef = useRef(columns) - // Refs for the mobile scroll-settle listener so the effect doesn't re-attach - // every time `activeColumnId` or `columns` flip — both can change rapidly. + // Ref mirror of activeColumnId so the mobile IntersectionObserver callback + // can compare against the current value without re-attaching the observer + // every time the active column flips. const activeColumnIdRef = useRef(activeColumnId) - const columnsRef = useRef(columns) useEffect(() => { activeColumnIdRef.current = activeColumnId }, [activeColumnId]) - useEffect(() => { - columnsRef.current = columns - }, [columns]) // Centralized "scroll this column into view" helper. Uses the data-column-id // attribute already set by SortableColumn. No-op if the node can't be found @@ -198,67 +196,50 @@ export default function DeckArea() { } }, [columns, activeColumnId, setActiveColumnId, focusBeamActive, setFocusBeamActive, scrollColumnIntoView]) - // WS3: mobile scroll-settle → setActiveColumnId. Watches horizontal scroll - // on the deck scroller, finds the closest snapped column, and updates the - // active id. NEVER calls scrollColumnIntoView from this handler — that - // would create a feedback loop (scroll → set active → scroll → ...). - // Debounced via rAF + setTimeout so we only fire on settle, not mid-swipe. + // WS3: mobile active-column tracking → setActiveColumnId, via an + // IntersectionObserver on the deck scroller. We previously debounced + // `scroll`/`scrollend`, but `scrollend` only shipped in Safari 18.2 and iOS + // momentum scrolling throttles `scroll`, so the page-dot indicator got stuck + // on the old column after a swipe. The IO reports each column wrapper's + // visible ratio as the user pans; the most-visible column (≥50%) wins and + // becomes active, so the dots update live. Re-observes when the column set + // changes. NEVER calls scrollColumnIntoView from here — that would feed back + // into the scroll position. useEffect(() => { if (!isSmallScreen) return const scroller = scrollerRef.current if (!scroller) return - let timer: number | null = null - let rafId: number | null = null - const onSettle = () => { - const cols = columnsRef.current - if (!cols.length) return - const scrollLeft = scroller.scrollLeft - // Pick the column whose left edge is closest to the current scroll - // position. The SortableColumn wrappers are *direct children* of the - // scroller — we walk `scroller.children` to avoid matching the inner - // (which ALSO carries `data-column-id` for the active-click - // capture). `getBoundingClientRect` gives absolute layout positions; - // subtracting the scroller's own left + adding scrollLeft yields each - // wrapper's offset within the scrollable content. - const scrollerLeft = scroller.getBoundingClientRect().left - let bestId: string | null = null - let bestDist = Infinity - for (const child of Array.from(scroller.children) as HTMLElement[]) { - const id = child.dataset.columnId - if (!id) continue // AddColumnPlaceholder etc. have no id - const childLeftInContent = - child.getBoundingClientRect().left - scrollerLeft + scrollLeft - const dist = Math.abs(childLeftInContent - scrollLeft) - if (dist < bestDist) { - bestDist = dist - bestId = id + const ratios = new Map() + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.columnId + if (id) ratios.set(id, entry.intersectionRatio) } - } - if (bestId && bestId !== activeColumnIdRef.current) { - setActiveColumnId(bestId) - } - } - const onScroll = () => { - if (timer) clearTimeout(timer) - if (rafId) cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(() => { - timer = window.setTimeout(onSettle, 120) - }) - } - scroller.addEventListener('scroll', onScroll, { passive: true }) - // 'scrollend' is iOS 16+ / modern Chrome — preferred when available - // because it fires exactly once per settle. The rAF+timeout fallback - // above still runs but is harmless (it idempotently re-confirms the - // current snapped column). - scroller.addEventListener('scrollend', onSettle, { passive: true }) - return () => { - scroller.removeEventListener('scroll', onScroll) - scroller.removeEventListener('scrollend', onSettle) - if (timer) clearTimeout(timer) - if (rafId) cancelAnimationFrame(rafId) + let bestId: string | null = null + let bestRatio = 0 + for (const [id, ratio] of ratios) { + if (ratio > bestRatio) { + bestRatio = ratio + bestId = id + } + } + // Require a clear majority so we flip exactly once per swipe (near the + // midpoint) rather than flickering between two half-visible columns. + if (bestId && bestRatio >= 0.5 && bestId !== activeColumnIdRef.current) { + setActiveColumnId(bestId) + } + }, + { root: scroller, threshold: [0, 0.25, 0.5, 0.75, 1] } + ) + // Observe the SortableColumn wrappers (direct children carrying + // data-column-id). The AddColumnPlaceholder has no id and is skipped. + for (const child of Array.from(scroller.children) as HTMLElement[]) { + if (child.dataset.columnId) observer.observe(child) } - }, [isSmallScreen, setActiveColumnId]) + return () => observer.disconnect() + }, [isSmallScreen, setActiveColumnId, columns.length]) // Cross-provider focus/scroll request channel. ColumnsProvider sets // `focusedColumnRequestAtom` on re-click of an already-open transient @@ -309,7 +290,15 @@ export default function DeckArea() { ref={scrollerRef} data-deck-scroll="" className={cn( - 'flex h-full gap-3 overflow-x-auto p-2 max-md:snap-x max-md:snap-mandatory', + // Mobile is edge-to-edge: no side/top padding so each full-viewport + // column bleeds to the screen edges (gap-3 is just the swipe gutter + // between pages). The 8px top padding also read as a dark gap above + // the column in the terminal preset. Desktop keeps the floating-card + // padding (md:px-2 / md:pt-2). + // overscroll-x-contain stops a left-edge swipe past the first column + // from chaining into Safari's history back-gesture (which navigated + // away from the SPA and flashed a white page). + 'flex h-full gap-3 overflow-x-auto overscroll-x-contain px-0 pb-2 pt-0 md:px-2 md:pt-2 max-md:snap-x max-md:snap-mandatory', deckLeadingGutter && 'md:ps-32' )} > @@ -337,11 +326,6 @@ export default function DeckArea() { setActiveColumnId(id) scrollColumnIntoView(id) }} - onJumpToAddPlaceholder={() => { - const scroller = scrollerRef.current - if (!scroller) return - scroller.scrollTo({ left: scroller.scrollWidth, behavior: 'instant' }) - }} /> )} {/* Focus Beam scrim — viewport-fixed overlay above the deck (z-40), @@ -352,6 +336,7 @@ export default function DeckArea() { viewport. */} {scrimMounted && !isSmallScreen && } + ) } diff --git a/src/components/DeckSwitcher/Dropdown.tsx b/src/components/DeckSwitcher/Dropdown.tsx index 521d71f..274f43d 100644 --- a/src/components/DeckSwitcher/Dropdown.tsx +++ b/src/components/DeckSwitcher/Dropdown.tsx @@ -112,7 +112,7 @@ export default function DeckSwitcher() { aria-haspopup="menu" aria-expanded={open} > - + {chipState.name} {chipState.showDirtyPip && ( diff --git a/src/components/MobileNavStack/index.tsx b/src/components/MobileNavStack/index.tsx new file mode 100644 index 0000000..4521b6b --- /dev/null +++ b/src/components/MobileNavStack/index.tsx @@ -0,0 +1,221 @@ +// src/components/MobileNavStack/index.tsx +// +// Mobile native push/pop navigation. In-feed drill-downs (note threads, +// profiles, settings, ...) append to mobileNavStackAtom and render here as +// full-screen pushed screens that slide in from the right, instead of spawning +// deck columns. Each screen is a SECONDARY_ROUTES page — which already renders +// its own back-chevron titlebar (SecondaryPageLayout) wired to this stack's +// pop(). Lower layers stay mounted (no display:none — that would restart their +// slide-in animation on reveal, making "back" wrongly animate from the right) +// so their scroll/state survive a back-out and the previous screen is simply +// revealed beneath the top one. +// +// Back: the back chevron, the hardware/browser back (popstate in DeckManager), +// and an edge swipe-back gesture all pop. The top layer follows the finger on a +// left-edge drag and slides off to the right past the threshold; the chevron +// animates the same slide-out. +// +// The stack sits above the deck + TopBar but stops short of the BottomBar, so +// the Overview button stays tappable as the one-tap "back to the deck" escape +// from any depth. push() delegates to the outer (DeckManager) push so it goes +// through the same screen-vs-column classifier. +import { mobileNavStackAtom } from '@/atoms/mobile-nav-stack' +import { SecondaryPageContext, useSecondaryPage } from '@/DeckManager' +import { normalizeToSecondaryRoute } from '@/lib/link' +import { MutedThreadRevealProvider } from '@/providers/MutedThreadRevealProvider' +import { ScrollContainerProvider } from '@/providers/ScrollContainerProvider' +import { SECONDARY_ROUTES } from '@/routes/secondary' +import { useAtom } from 'jotai' +import { + cloneElement, + createRef, + ReactElement, + RefObject, + TouchEvent as ReactTouchEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' +import { useTranslation } from 'react-i18next' + +const EDGE_START_PX = 32 // gesture must begin within this of the left edge +const CLOSE_FRACTION = 0.33 // drag past this fraction of width → pop +const ANIM_MS = 280 + +function buildElement( + url: string, + index: number, + ref: RefObject +): ReactElement | null { + const path = normalizeToSecondaryRoute(url).split('?')[0].split('#')[0] + for (const { matcher, element } of SECONDARY_ROUTES) { + const m = matcher(path) + if (!m || !element) continue + return cloneElement(element, { ...m.params, index, ref } as Record) + } + return null +} + +type LayerRefs = { scroll: RefObject; page: RefObject } + +export default function MobileNavStack() { + const { t } = useTranslation() + const [stack, setStack] = useAtom(mobileNavStackAtom) + const outer = useSecondaryPage() + const refs = useRef(new Map()) + + // Top-layer horizontal offset: 0 at rest, follows the finger on swipe-back, + // and animates to the viewport width on close. `dragging` disables the + // transition for 1:1 finger tracking. + const [dragX, setDragX] = useState(0) + const [dragging, setDragging] = useState(false) + const gestureRef = useRef<{ x: number; y: number; engaged: boolean } | null>(null) + const closingRef = useRef(false) + + const currentIndex = stack.length - 1 + const topId = stack[currentIndex]?.id + + // Reset the offset whenever the top screen changes (push or pop). Push-in is + // handled by the CSS slide-in on mount; this just keeps the resting offset 0. + useEffect(() => { + setDragX(0) + setDragging(false) + }, [topId]) + + const closeTop = useCallback(() => { + if (closingRef.current) return + closingRef.current = true + setDragging(false) + setDragX(window.innerWidth) // transition slides the top off to the right + window.setTimeout(() => { + setStack((prev) => prev.slice(0, -1)) + setDragX(0) + closingRef.current = false + }, ANIM_MS) + }, [setStack]) + + // Screens push through the outer (DeckManager) push so the screen-vs-column + // classifier still applies; they pop within this stack (animated). + const value = useMemo( + () => ({ push: outer.push, pop: closeTop, currentIndex }), + [outer.push, closeTop, currentIndex] + ) + + const onTouchStart = (e: ReactTouchEvent) => { + if (closingRef.current) return + const tch = e.touches[0] + if (tch.clientX > EDGE_START_PX) { + gestureRef.current = null + return + } + gestureRef.current = { x: tch.clientX, y: tch.clientY, engaged: false } + } + + const onTouchMove = (e: ReactTouchEvent) => { + const g = gestureRef.current + if (!g) return + const tch = e.touches[0] + const dx = tch.clientX - g.x + const dy = tch.clientY - g.y + if (!g.engaged) { + // Decide once: vertical-dominant → let the page scroll; horizontal → swipe. + if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 10) { + gestureRef.current = null + return + } + if (dx > 10) { + g.engaged = true + setDragging(true) + } else { + return + } + } + setDragX(Math.max(0, dx)) + } + + const onTouchEnd = () => { + const g = gestureRef.current + gestureRef.current = null + if (!g?.engaged) return + setDragging(false) + if (dragX > window.innerWidth * CLOSE_FRACTION) closeTop() + else setDragX(0) // snap back via transition + } + + if (stack.length === 0) return null + + const getRefs = (id: string): LayerRefs => { + let r = refs.current.get(id) + if (!r) { + r = { scroll: createRef(), page: createRef() } + refs.current.set(id, r) + } + return r + } + + const renderLayer = ( + entry: { id: string; url: string }, + idx: number, + isTop: boolean + ) => { + const r = getRefs(entry.id) + const element = buildElement(entry.url, idx, r.page) + return ( +
+ + {element ?? ( +
+ {t('Cannot display this content')} +
+ )} +
+
+ ) + } + + return ( + + + {/* Opaque backdrop hides the deck while any screen is open, so a + swipe-back reveals a clean surface instead of the feed you came from + (which often contains the very note you opened — that's what read as + a doubled/second screen behind). The deck reappears once the last + screen is fully popped. */} +
+ {/* Lower layers: static opaque screens beneath the top one. */} + {stack.slice(0, currentIndex).map((entry, idx) => renderLayer(entry, idx, false))} + {/* Top layer: draggable, slides over the layer/backdrop beneath it. */} + {renderLayer(stack[currentIndex], currentIndex, true)} + + + ) +} diff --git a/src/components/TopBar/index.tsx b/src/components/TopBar/index.tsx index 6dc646f..b55e8c0 100644 --- a/src/components/TopBar/index.tsx +++ b/src/components/TopBar/index.tsx @@ -1,8 +1,8 @@ /** * TopBar — single chrome region for the app shell. * - * Mobile: brand · empty middle · Add column · AccountButton compact - * (BottomBar handles Home + Post; swipe + page-dot covers nav) + * Mobile: brand · DeckSwitcher · spacer · AgentChat · Account + * (BottomBar handles Overview + New column + Compose; page-dots cover nav) * Desktop: brand · QuickJumps (5 icons) · spacer · Compose · Add column · AccountButton compact * * Surfaces Settings / Wallet / account switch via the AccountButton dropdown, @@ -11,8 +11,14 @@ */ import DeckSwitcher from '@/components/DeckSwitcher' import JankMark from '@/components/JankMark' +import { columnOverviewOpenAtom } from '@/atoms/active-column' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useSetAtom } from 'jotai' +import { LayoutGrid } from 'lucide-react' import { useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useTranslation } from 'react-i18next' import AccountButton from './AccountButton' import AddColumnButton from './AddColumnButton' import AgentChatButton from './AgentChatButton' @@ -21,7 +27,9 @@ import MobileWarningBanner from './MobileWarningBanner' import QuickJumps from './QuickJumps' export default function TopBar() { + const { t } = useTranslation() const { isSmallScreen } = useScreenSize() + const setOverviewOpen = useSetAtom(columnOverviewOpenAtom) // Brand tap → scroll the deck horizontally back to its first column. Home // is the only primary "page" in Phase 2 so no navigate is needed. @@ -47,13 +55,29 @@ export default function TopBar() { > - {!isSmallScreen && } + {!isSmallScreen && }
{!isSmallScreen && } + {!isSmallScreen && ( + + + + + {t('Show all columns')} + + )} - + {/* Mobile gets New column in the bottom bar; keep it here on desktop. */} + {!isSmallScreen && }
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 94efcc9..63c516d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1097,6 +1097,8 @@ export default { 'Only you can see this mute. Click to make it public.': 'Only you can see this mute. Click to make it public.', 'Anyone can see this mute. Click to make it private.': - 'Anyone can see this mute. Click to make it private.' + 'Anyone can see this mute. Click to make it private.', + 'Show all columns': 'Show all columns', + 'Jump to column': 'Jump to column' } } diff --git a/src/index.css b/src/index.css index 5a420e3..d82302a 100644 --- a/src/index.css +++ b/src/index.css @@ -132,7 +132,9 @@ body, html { - overscroll-behavior-y: none; + /* Both axes: -y stops vertical pull-to-refresh / rubber-band, -x stops the + page-level edge swipe from triggering Safari back/forward navigation. */ + overscroll-behavior: none; } button:not([disabled]), @@ -325,11 +327,51 @@ } @media (max-width: 768px) { :root { - --deck-col-width: calc(100vw - 1rem); - --deck-placeholder-width: calc(100vw - 1rem); + /* Edge-to-edge on mobile: columns fill the full viewport (one column per + page), so there's no side padding to subtract — only the horizontal + safe-area insets, so a column never sits under the notch in landscape. + Insets are 0 in portrait / on desktop. */ + --deck-col-width: calc(100vw - env(safe-area-inset-left) - env(safe-area-inset-right)); + --deck-placeholder-width: calc( + 100vw - env(safe-area-inset-left) - env(safe-area-inset-right) + ); } } +/* + iOS Safari zooms the page in whenever a focused /