diff --git a/app/api/v1/channels/[id]/head/route.ts b/app/api/v1/channels/[id]/head/route.ts new file mode 100644 index 0000000..c02dc7a --- /dev/null +++ b/app/api/v1/channels/[id]/head/route.ts @@ -0,0 +1,30 @@ +import { authenticate } from '@/lib/auth' +import { otherMethods } from '@/lib/endpoints' +import { toErrorResponse } from '@/lib/http' +import { lastMessageSeq, lastSeq } from '@/lib/items' +import { limitHeadProbes } from '@/lib/rate-limit' +import { getRedis } from '@/lib/redis' +import { toIso } from '@/lib/time' + +/** + * GET /api/v1/channels/:id/head — has anything happened, and has anyone spoken. + * + * For a browser asking about a room nobody is looking at, on a timer. It reads + * the channel record and two counters and writes nothing: no sweep, no + * presence, no read receipt. A probe that marked you present in a room you are + * not in, or advanced a cursor other people are shown, would be lying. + */ +export async function GET(request: Request, context: RouteContext<'/api/v1/channels/[id]/head'>): Promise { + try { + const { id } = await context.params + const redis = await getRedis() + const { channel } = await authenticate(redis, id, ['invite', 'participant'], request) + await limitHeadProbes(redis, channel.id, request) + const [seq, messageSeq] = await Promise.all([lastSeq(redis, channel.id), lastMessageSeq(redis, channel.id)]) + return Response.json({ last_seq: seq, last_message_seq: messageSeq, expires_at: toIso(channel.expires_at) }) + } catch (error) { + return toErrorResponse(error) + } +} + +export const { POST, PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels/{id}/head') diff --git a/app/c/[id]/page.tsx b/app/c/[id]/page.tsx index 7fd89a8..32f2085 100644 --- a/app/c/[id]/page.tsx +++ b/app/c/[id]/page.tsx @@ -22,5 +22,5 @@ export async function generateMetadata({ params }: PageProps<'/c/[id]'>): Promis export default async function ChannelPage({ params }: PageProps<'/c/[id]'>) { const { id } = await params - return + return } diff --git a/app/c/layout.tsx b/app/c/layout.tsx new file mode 100644 index 0000000..b93bb4c --- /dev/null +++ b/app/c/layout.tsx @@ -0,0 +1,10 @@ +import { ChannelsPane } from '@/app/components/channel/channel-list' + +export default function ChannelLayout({ children }: LayoutProps<'/c'>) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/app/c/page.tsx b/app/c/page.tsx new file mode 100644 index 0000000..8c0e5fd --- /dev/null +++ b/app/c/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import { siteName } from '@/lib/site' + +export const metadata: Metadata = { + title: `Channels · ${siteName}`, + robots: { index: false, follow: false }, +} + +/** The list alone. The layout draws it; beside it on a computer there is nothing open yet. */ +export default function ChannelsPage() { + return
+} diff --git a/app/components/channel/channel-events.test.ts b/app/components/channel/channel-events.test.ts index b777385..6258f7e 100644 --- a/app/components/channel/channel-events.test.ts +++ b/app/components/channel/channel-events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { announcementFor } from './channel-events' +import { announcementFor, joinedSentence } from './channel-events' import type { Item } from './use-channel' const event = (name: string, text?: string): Item => ({ @@ -45,3 +45,14 @@ describe('announcementFor', () => { expect(announcementFor(event('participant.joined'))).toBeNull() }) }) + +describe('joinedSentence', () => { + it.each([ + [['Mac agent'], 'Mac agent joined'], + [['Mac agent', 'Windows agent'], 'Mac agent and Windows agent joined'], + [['A', 'B', 'C'], 'A, B and C joined'], + [['A', 'B', 'C', 'D', 'E'], 'A, B and 3 others joined'], + ])('names %j', (names, sentence) => { + expect(joinedSentence(names)).toBe(sentence) + }) +}) diff --git a/app/components/channel/channel-events.ts b/app/components/channel/channel-events.ts index db85de7..a683490 100644 --- a/app/components/channel/channel-events.ts +++ b/app/components/channel/channel-events.ts @@ -34,3 +34,10 @@ export type Announcement = { title: string; kind: 'info' | 'warning' } export function announcementFor(item: Item): Announcement | null { return item.type === 'system' ? announcement(item) : null } + +/** Joins that arrive back to back, as the one sentence the transcript draws for them. */ +export function joinedSentence(names: readonly string[]): string { + if (names.length <= 1) return `${names[0] ?? 'Someone'} joined` + if (names.length > 3) return `${names.slice(0, 2).join(', ')} and ${names.length - 2} others joined` + return `${names.slice(0, -1).join(', ')} and ${names.at(-1)} joined` +} diff --git a/app/components/channel/channel-list.tsx b/app/components/channel/channel-list.tsx new file mode 100644 index 0000000..a4ed12e --- /dev/null +++ b/app/components/channel/channel-list.tsx @@ -0,0 +1,190 @@ +'use client' + +import Link from 'next/link' +import { useParams } from 'next/navigation' +import { useEffect, useRef, useState } from 'react' +import { byActivity, live, type VisitedChannel } from '@/lib/visited-channels' +import { CreateChannelButton } from '../create-channel' +import { CloseIcon, PlusIcon } from '../icons' +import { Logo } from '../logo' +import { useHeadProbe } from './use-head-probe' +import { unreadOf, useUnreadVersion } from './use-unread' +import { forgetChannel, rememberChannel, useVisitedChannels } from './use-visited-channels' + +const UNDO_MS = 5_000 + +function RowName({ name, className }: { name: string; className: string }) { + return ( + + + # + + {name || 'unnamed-channel'} + + ) +} + +function OtherRow({ + entry, + unread, + onForget, +}: { + entry: VisitedChannel + unread: boolean + onForget: (entry: VisitedChannel) => void +}) { + return ( +
  • + {/* Link, not : a soft navigation commits the fragment before the + next page reads its invite (#69). No prefetch, since each visible + row would otherwise be a request to the server. */} + + + {unread ? , unread : null} + + {unread ? ( + + ) : null} + +
  • + ) +} + +export function ChannelList() { + const { id: currentId } = useParams<{ id?: string }>() + const stored = useVisitedChannels() + useUnreadVersion() + const [now, setNow] = useState(() => Date.now()) + const [forgotten, setForgotten] = useState(null) + const undo = useRef(null) + const list = useRef(null) + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 60_000) + return () => clearInterval(timer) + }, []) + + useEffect(() => { + if (!forgotten) return + undo.current?.focus() + const timer = setTimeout(() => { + if (document.activeElement === undo.current) list.current?.focus() + setForgotten(null) + }, UNDO_MS) + return () => clearTimeout(timer) + }, [forgotten]) + + const known = byActivity(live(stored, now)) + const forget = (entry: VisitedChannel) => { + forgetChannel(entry.id) + setForgotten(entry) + } + + return ( +
    +
      + {known.map((entry) => + entry.id === currentId ? ( +
    • + {/* A link rather than a label, so it is in the tab order and announced as where you are. */} + event.preventDefault()} + className="block cursor-default rounded-[8px] border border-line bg-panel px-2 py-1" + > + + +
    • + ) : ( + + ), + )} + + {forgotten ? ( +
    • + Forgot #{forgotten.name || 'unnamed-channel'} + +
    • + ) : null} +
    + + {known.every((entry) => entry.id === currentId) && !forgotten ? ( +

    + {known.length === 0 + ? 'Channels you open in this browser will be listed here.' + : 'Other channels you open in this browser will be listed here.'} +

    + ) : null} +
    + ) +} + +export const channelListNote = 'Kept in this browser only, and gone when each room expires.' + +/** + * Lives in the /c layout, not the page, so it stays put while the channel + * beside it is swapped. + */ +export function ChannelsPane() { + useHeadProbe() + const { id } = useParams<{ id?: string }>() + // At /c the list is the page, so on a phone it takes the whole width; beside a channel it is a desktop column. + const index = id === undefined + return ( + + ) +} diff --git a/app/components/channel/channel-menu.tsx b/app/components/channel/channel-menu.tsx index f139524..95f4b48 100644 --- a/app/components/channel/channel-menu.tsx +++ b/app/components/channel/channel-menu.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { sileo } from 'sileo' -import { CloseIcon, MoreIcon, PlusIcon, ShareIcon } from '../icons' +import { CloseIcon, CopyIcon, MoreIcon, PlusIcon, ShareIcon } from '../icons' import { copyText } from './copy-text' /** @@ -69,6 +69,31 @@ export function ChannelShareButton({ url, channelName }: { url: string; channelN ) } +const keyWarning = 'Anyone with it can join and read the channel. It carries the invite, so treat it like a key.' + +/** + * The desktop's share: a computer has no share sheet worth opening, so the + * link goes to the clipboard, and the warning that used to stand in the side + * pane arrives with it, at the one moment it applies. + */ +export function ChannelCopyLinkButton({ url }: { url: string }) { + return ( + + ) +} + export function ChannelMenu({ open, onClose, diff --git a/app/components/channel/channel-view.tsx b/app/components/channel/channel-view.tsx index 0aedcac..94a1112 100644 --- a/app/components/channel/channel-view.tsx +++ b/app/components/channel/channel-view.tsx @@ -9,15 +9,30 @@ import { quoteOf } from '@/lib/reply-quote' import { channelGone } from '@/lib/site' import mark from '../../icon.png' import { CreateChannelForm } from '../create-channel' -import { ArrowRightIcon } from '../icons' -import { Logo } from '../logo' +import { ArrowRightIcon, ChevronLeftIcon, PlusIcon } from '../icons' import { ReplyProvider } from '../reply-action' -import { Roster, Transcript, type ReplyQuote, type TranscriptItem } from '../transcript' +import { seatingOf } from '@/lib/seating' +import type { IdentityColor } from '@/lib/identity-color' +import { + IdentityTile, + PresenceDot, + Roster, + Transcript, + type Participant, + type ReplyQuote, + type TranscriptItem, +} from '../transcript' import { AddAgentDialog } from './add-agent-dialog' -import { announcementFor } from './channel-events' -import { ChannelAddButton, ChannelMenu, ChannelMenuButton, ChannelShareButton } from './channel-menu' +import { announcementFor, joinedSentence } from './channel-events' +import { + ChannelAddButton, + ChannelCopyLinkButton, + ChannelMenu, + ChannelMenuButton, + ChannelShareButton, +} from './channel-menu' import { Compose } from './compose' -import { Controls, ExpiryCountdown } from './controls' +import { ChannelActions, ExpiryCountdown } from './controls' import { PromptBox } from './prompt-box' import { useReadMarker } from './use-read-marker' import { adminKey, useChannel, type Item, type PendingMessage, type RosterEntry } from './use-channel' @@ -77,24 +92,40 @@ function quoteFor(bySeq: Map, seq: number): ReplyQuote | undefined */ function toTranscript(items: Item[], pending: PendingMessage[]): TranscriptItem[] { const bySeq = new Map(items.map((item) => [item.seq, item])) - const said: TranscriptItem[] = items.map((item) => - item.type === 'system' - ? { seq: item.seq, type: 'system', text: describe(item) } - : { - seq: item.seq, - type: 'message', - from: { name: item.from.name, role: item.from.role }, - time: clockTime(item.ts), - text: item.text, - ...(item.reply_to === undefined ? {} : { replyTo: quoteFor(bySeq, item.reply_to) }), - }, - ) + const said: TranscriptItem[] = [] + let joined: string[] = [] + for (const item of items) { + if (item.type === 'system') { + // Joins that land back to back are one arrival, and read as one line. + const name = item.event === 'participant.joined' ? item.subject?.name : undefined + const last = said.at(-1) + if (name && joined.length > 0 && last?.type === 'system') { + joined.push(name) + said[said.length - 1] = { seq: item.seq, type: 'system', text: joinedSentence(joined) } + continue + } + joined = name ? [name] : [] + said.push({ seq: item.seq, type: 'system', text: describe(item) }) + continue + } + joined = [] + said.push({ + seq: item.seq, + type: 'message', + from: { name: item.from.name, role: item.from.role }, + time: clockTime(item.ts), + ts: item.ts, + text: item.text, + ...(item.reply_to === undefined ? {} : { replyTo: quoteFor(bySeq, item.reply_to) }), + }) + } for (const draft of pending) { said.push({ type: 'message', from: { name: draft.name, role: 'human' }, time: clockTime(draft.ts), + ts: draft.ts, text: draft.text, ...(draft.replyTo === undefined ? {} : { replyTo: quoteFor(bySeq, draft.replyTo) }), pending: true, @@ -138,14 +169,19 @@ function toRoster(participants: RosterEntry[], items: Item[], lastSeq: number, m } function Shell({ children, ground = false }: { children: React.ReactNode; ground?: boolean }) { - // A definite height, not a minimum: `flex-1` below is `flex-basis: 0%`, and - // against an indefinite height that resolves to the content's own, so the - // transcript grew instead of scrolling and took the bar and composer off screen. + // The height comes from the /c layout's h-dvh, and has to stay definite: + // `flex-1` below is `flex-basis: 0%`, and against an indefinite height that + // resolves to the content's own, so the transcript grew instead of scrolling + // and took the bar and composer off screen. // // White is the channel: an application surface, edge to edge. The pages that // stand in for a channel are documents instead, so they take the ground the // landing page and the 404 stand on, and their panels read as panels again. - return
    {children}
    + return ( +
    + {children} +
    + ) } /** The bar across the top: what this channel is, and how long it has left. */ @@ -163,18 +199,19 @@ function TopBar({ className={`flex shrink-0 items-center justify-between gap-3 px-4 py-2.5 ${rule ? 'border-b border-line' : ''}`} >
    - - {children ? : null} + + + + + {children ? : null} + {children}
    -
    - {/* On a phone the channel's actions live in the sheet, so the bar keeps - one control instead of three competing for the same 375 pixels. */} - - New channel - - {menu} -
    +
    {menu}
    ) } @@ -287,6 +324,105 @@ function Notice({ title, children }: { title: string; children: React.ReactNode ) } +/** Who is in the room, as overlapping tiles: the room at a glance where the roster is not on screen. */ +function PresenceStack({ + room, + colorFor, + size = 'md', +}: { + room: Participant[] + colorFor: (name: string, role: Participant['role']) => IdentityColor + size?: 'sm' | 'md' +}) { + const seatOf = seatingOf(room) + const shown = room.slice(0, 4) + const more = room.length - shown.length + return ( + + {shown.map((person, index) => ( + 0 ? '-ml-1.5' : ''} ${size === 'sm' ? 'scale-[0.83]' : ''}`} + > + + + ))} + {more > 0 ? +{more} : null} + + ) +} + +/** + * An empty channel's one piece of business: getting agents in. The steps and + * the seats sit beside the prompt on a wide screen and above it on a narrow + * one, and the seats fill in as agents arrive, so the page answers "did it + * work" without anyone having to look elsewhere. + */ +function Setup({ + room, + colorFor, + children, +}: { + room: Participant[] + colorFor: (name: string, role: Participant['role']) => IdentityColor + children: React.ReactNode +}) { + const seatOf = seatingOf(room) + const steps = [ + 'Name the agent and pick how it runs.', + 'Copy the prompt and paste it into the agent. It joins the channel by itself.', + 'Do it again for each agent, changing the name each time.', + ] + return ( +
    +
    +
    +

    + Nobody has spoken yet +

    +

    + Agents join with the prompt. Once one of them says something, the conversation takes this space. +

    +
    + +
      + {steps.map((step, index) => ( +
    1. + + {index + 1} + + {step} +
    2. + ))} +
    + +
      + {room.map((person) => ( +
    • + + {person.name} + joined + + + +
    • + ))} +
    • + + {room.some((person) => person.role === 'agent') + ? 'Waiting for the next agent' + : 'Waiting for the first agent'} +
    • +
    +
    + +
    + {children} +
    +
    + ) +} + export function ChannelView({ channelId, host }: { channelId: string; host: string }) { const { status, channel, items, pending, participants, me, error, invite, historyUpTo, lastSeq, post, closeChannel } = useChannel(channelId) @@ -294,7 +430,16 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri const [menuOpen, setMenuOpen] = useState(false) /** The seq the next message answers, set by a row's Reply and cleared once it is sent. */ const [replyingTo, setReplyingTo] = useState(null) - const { scroller, tail, markerAt, onScroll } = useReadMarker(channelId, items, status === 'ready', pending.length) + // Joins alone do not start a conversation: the prompt stays put while agents + // are still arriving, and steps aside once one of them says something. + const started = pending.length > 0 || items.some((item) => item.type === 'message') + const { scroller, tail, markerAt, onScroll } = useReadMarker( + channelId, + items, + status === 'ready', + pending.length, + started, + ) // The toast store is a module singleton, and Next hands each client boundary // its own copy, so the calls have to be made from the module that renders the @@ -357,9 +502,6 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri // One palette for the whole page, so a name reads the same in the room and the transcript. const colorFor = identityPalette(participants) - // Joins alone do not start a conversation: the prompt stays put while agents - // are still arriving, and steps aside once one of them says something. - const started = pending.length > 0 || items.some((item) => item.type === 'message') const room = toRoster(participants, items, lastSeq, me?.id) const replyQuote = replyingTo === null ? null : (quoteFor(new Map(items.map((i) => [i.seq, i])), replyingTo) ?? null) // Cleared on success only: a post that failed hands its text back to the @@ -377,28 +519,62 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri + + + + {participants.length} of {channel.max_participants} + + + + {started ? ( + + ) : null} setAdding(true)} /> setMenuOpen(true)} /> } > -
    - {channel.name || 'Unnamed channel'} - · - {channel.mode} - - · - - - {participants.length} of {channel.max_participants} - - - · - - - - +
    +
    + + {channel.name || 'Unnamed channel'} + + + · + + {channel.mode} + + · + + + + +
    + {/* Below the name on a phone, where the roster is behind the menu: who is here, and the way to them. */} +
    @@ -410,8 +586,8 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri className="pane-scroll flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-4 lg:px-6" > {/* A running conversation sits on the composer; an empty channel - centres its one piece of business instead. */} -
    + reads from the top, as the setup page it is. */} +
    {started ? ( ) : ( -
    -
    -

    Nobody has spoken yet

    -

    - Copy this into an agent and it will join the channel. Paste one per agent, changing the name each - time, then watch them here. -

    -
    -
    - -
    -
    + + + )}
    @@ -460,7 +627,7 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri