Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/api/v1/channels/[id]/head/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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')
2 changes: 1 addition & 1 deletion app/c/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ChannelView channelId={id} host={getConfig().host} />
return <ChannelView key={id} channelId={id} host={getConfig().host} />
}
10 changes: 10 additions & 0 deletions app/c/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ChannelsPane } from '@/app/components/channel/channel-list'

export default function ChannelLayout({ children }: LayoutProps<'/c'>) {
return (
<div className="flex h-dvh overflow-hidden bg-panel">
<ChannelsPane />
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
)
}
12 changes: 12 additions & 0 deletions app/c/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div aria-hidden className="hidden flex-1 bg-panel lg:block" />
}
13 changes: 12 additions & 1 deletion app/components/channel/channel-events.test.ts
Original file line number Diff line number Diff line change
@@ -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 => ({
Expand Down Expand Up @@ -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)
})
})
7 changes: 7 additions & 0 deletions app/components/channel/channel-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
190 changes: 190 additions & 0 deletions app/components/channel/channel-list.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`block truncate text-[13.5px] leading-snug ${className}`}>
<b aria-hidden className="mr-1 font-bold text-ink-3">
#
</b>
{name || 'unnamed-channel'}
</span>
)
}

function OtherRow({
entry,
unread,
onForget,
}: {
entry: VisitedChannel
unread: boolean
onForget: (entry: VisitedChannel) => void
}) {
return (
<li className="group relative">
{/* Link, not <a>: 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. */}
<Link
href={`/c/${entry.id}#${entry.invite}`}
prefetch={false}
className="block rounded-[8px] border border-transparent px-2 py-1 pr-14 transition-colors hover:bg-panel pointer-fine:pr-8"
>
<RowName
name={entry.name}
className={unread ? 'font-semibold text-ink' : 'font-medium text-ink-2 group-hover:text-ink'}
/>
{unread ? <span className="sr-only">, unread</span> : null}
</Link>
{unread ? (
<span
aria-hidden
className="pointer-events-none absolute right-9 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-ink transition-opacity pointer-fine:right-3 pointer-fine:group-focus-within:opacity-0 pointer-fine:group-hover:opacity-0"
/>
) : null}
<button
type="button"
onClick={() => onForget(entry)}
aria-label={`Forget #${entry.name || 'unnamed-channel'}`}
title="Forget this channel in this browser"
className="absolute right-1 top-1/2 grid size-6 -translate-y-1/2 place-items-center rounded-[6px] text-ink-3 transition-[opacity,color,background-color] hover:bg-line-2 hover:text-ink pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 pointer-fine:focus-visible:opacity-100"
>
<CloseIcon size={13} />
</button>
</li>
)
}

export function ChannelList() {
const { id: currentId } = useParams<{ id?: string }>()
const stored = useVisitedChannels()
useUnreadVersion()
const [now, setNow] = useState(() => Date.now())
const [forgotten, setForgotten] = useState<VisitedChannel | null>(null)
const undo = useRef<HTMLButtonElement>(null)
const list = useRef<HTMLUListElement>(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 (
<div className="grid gap-3">
<ul ref={list} tabIndex={-1} className="m-0 grid list-none gap-px p-0 outline-none">
{known.map((entry) =>
entry.id === currentId ? (
<li key={entry.id}>
{/* A link rather than a label, so it is in the tab order and announced as where you are. */}
<Link
href={`/c/${entry.id}`}
aria-current="page"
onClick={(event) => event.preventDefault()}
className="block cursor-default rounded-[8px] border border-line bg-panel px-2 py-1"
>
<RowName name={entry.name} className="font-semibold text-ink" />
</Link>
</li>
) : (
<OtherRow key={entry.id} entry={entry} unread={unreadOf(entry.id, now) === 'unread'} onForget={forget} />
),
)}

{forgotten ? (
<li role="status" className="flex items-center justify-between gap-2 px-2 py-1 text-[12.5px] text-ink-3">
<span className="min-w-0 truncate">Forgot #{forgotten.name || 'unnamed-channel'}</span>
<button
ref={undo}
type="button"
className="shrink-0 font-semibold text-ink-2 underline underline-offset-2 hover:text-ink"
onClick={() => {
rememberChannel(forgotten, forgotten.lastSeenAt)
setForgotten(null)
}}
>
Undo
</button>
</li>
) : null}
</ul>

{known.every((entry) => entry.id === currentId) && !forgotten ? (
<p className="m-0 px-2 text-[12.5px] leading-relaxed text-ink-3">
{known.length === 0
? 'Channels you open in this browser will be listed here.'
: 'Other channels you open in this browser will be listed here.'}
</p>
) : null}
</div>
)
}

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 (
<nav
className={`${index ? 'flex w-full lg:w-[240px]' : 'hidden w-[240px] lg:flex'} shrink-0 flex-col border-line bg-panel-2 lg:border-r`}
aria-label="Channels"
>
<div className="box-content flex h-10 shrink-0 items-center justify-between gap-3 border-b border-line px-4 py-2.5">
<Logo size={24} />
</div>
<div className="pane-scroll min-h-0 flex-1 overflow-y-auto px-2 py-4">
<div className="mb-1 flex items-center justify-between gap-2 pl-2">
<h2 className="m-0 font-sans text-[12.5px] font-semibold uppercase tracking-[0.02em] text-ink-3">Channels</h2>
<span className="relative">
<CreateChannelButton className="tip-trigger grid size-7 place-items-center rounded-[8px] text-ink-3 transition-colors hover:bg-line-2 hover:text-ink pointer-coarse:size-9">
<PlusIcon size={16} />
<span className="sr-only">New channel</span>
</CreateChannelButton>
<span className="choice-tip choice-tip-below" aria-hidden>
New channel
</span>
</span>
</div>
<ChannelList />
</div>
<p className="m-0 shrink-0 border-t border-line px-4 py-3 text-[12px] leading-relaxed text-ink-3">
{channelListNote}
</p>
</nav>
)
}
27 changes: 26 additions & 1 deletion app/components/channel/channel-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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 (
<button
type="button"
className="btn btn-sm btn-secondary hidden gap-2 lg:inline-flex"
title={`Copy the channel link. ${keyWarning}`}
onClick={async () => {
if (await copyText(url))
sileo.success({ title: 'Channel link copied', description: keyWarning, duration: 6_000 })
else sileo.warning({ title: 'The link could not be copied', duration: 5_000 })
}}
>
<CopyIcon size={15} />
Copy link
</button>
)
}

export function ChannelMenu({
open,
onClose,
Expand Down
Loading
Loading