Skip to content

Commit 206eda1

Browse files
committed
feat(posting): F40 — reply and quote
A thread can be answered. `ReplyComposer` adds only what F39's rules cannot see: a locked thread, a forum that takes threads but not replies, a thread that is no longer visible. Everything else — length limits, flood interval, moderation decision, one transaction for post plus counters — is F39's, reused. The race is reported, never enforced. The form carries the newest post the author had seen; on submit the composer compares it with the thread's current one and says so. Refusing would cost somebody their reply to protect them from an overlap that is usually harmless. The comparison happens after the write on purpose: checking first lets a reply landing in the same moment decide the answer, which is the race it is describing. Quoting is a link with a server-resolved prefill, so it needs no JavaScript — no button that edits a textarea, no island. The quoted post is re-read thread-scoped rather than trusted from the query string; without the thread in the lookup, `?quote=<id>` pastes any post on the board, including one from a forum the quoter cannot read, into a forum where everyone can. It emits BBCode nothing renders yet, because bodies are stored raw and rendered at read time: a quote written today becomes a real quote block the moment F36 lands, while a plain-text convention would be wrong forever. The redirect anchors to the new post. Posts page forward by id, so "which page is post N on" has no cheap answer; while the reply fits on the first page the anchor lands on it in context, and past that a cursor one below it opens a page beginning with it. That loses the posts above, and it is the stated price of not counting. Also fixes a divergence F39 introduced: the flood bypass was reading `content.viewUnapproved`, while docs/mybb-parity.md already recorded the mechanism as a board setting plus the `canBypassFloodCheck` boolean — which had no way to be asked for. There is now a global `flood.bypass` action, outside the F22 forum matrix because the interval is not a per-forum grant, and administrators bypass it like any other action. 32 tests, including the counter a reply must not move: getting `isNewThread` wrong inflates every ancestor's thread total by one per reply, which no reader would ever question. Two mutants killed on the reply path. See D43. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DkYV3smKzyXXkHGjJHFcX
1 parent 6979564 commit 206eda1

29 files changed

Lines changed: 1408 additions & 54 deletions

apps/forum/app/(board)/forum/[slug]/page.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { getActor } from "@/server/context";
77
import { activeTheme } from "@/server/theme";
88
import { decodeForumCursor, encodeForumCursor } from "@/view/forum-cursor";
99
import { buildForumDisplayView } from "@/view/forum-display";
10-
11-
const THREADS_PER_PAGE = 25;
10+
import { THREADS_PER_PAGE } from "@/view/paging";
1211

1312
export const metadata: Metadata = { title: "Forum" };
1413

apps/forum/app/(board)/thread/[slug]/page.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ import { requireSlot } from '@forum/theme-kit'
66
import { getContainer } from '@/server/container'
77
import { getActor } from '@/server/context'
88
import { activeTheme } from '@/server/theme'
9+
import { POSTS_PER_PAGE } from '@/view/paging'
910
import { buildThreadView } from '@/view/thread-view'
1011

11-
const POSTS_PER_PAGE = 20
12-
1312
export const metadata: Metadata = { title: 'Thread' }
1413

1514
function threadId(value: string): number | null {
@@ -32,7 +31,7 @@ export default async function ThreadPage({
3231
searchParams,
3332
}: {
3433
params: Promise<{ slug: string }>
35-
searchParams: Promise<{ after?: string; page?: string }>
34+
searchParams: Promise<{ after?: string; page?: string; replied?: string; posted?: string }>
3635
}) {
3736
const [{ slug }, query] = await Promise.all([params, searchParams])
3837
const id = threadId(slug)
@@ -41,7 +40,7 @@ export default async function ThreadPage({
4140
if (id === null || after === null || !Number.isSafeInteger(page) || page < 1) notFound()
4241

4342
const actor = await getActor()
44-
const { forums, posts, threads, authorizer, threadViews } = getContainer()
43+
const { forums, posts, threads, authorizer, threadViews, threadWrites } = getContainer()
4544
const thread = await threads.findVisibleById(id)
4645
if (!thread) notFound()
4746

@@ -68,8 +67,19 @@ export default async function ThreadPage({
6867
const nextHref = postPage.nextAfterId === null
6968
? null
7069
: `/thread/${thread.id}-${thread.slug}?after=${postPage.nextAfterId}&page=${page + 1}`
70+
/*
71+
* The reply link is offered only where the actor may actually use it, and a
72+
* locked thread offers it to nobody but a moderator — the same answer the
73+
* action gives, computed twice because a link is not authorisation.
74+
*/
75+
const canReply =
76+
threadWrites !== null &&
77+
authorizer.can(actor, 'reply.post', { forumId: forum.id, forum: matrix }) &&
78+
(!thread.isLocked || authorizer.can(actor, 'content.viewUnapproved', { forumId: forum.id, forum: matrix }))
79+
7180
const view = buildThreadView({
7281
thread,
82+
replyHref: canReply ? `/thread/${thread.id}-${thread.slug}/reply` : null,
7383
forum,
7484
page: postPage,
7585
pageNumber: page,
@@ -82,12 +92,29 @@ export default async function ThreadPage({
8292
})
8393

8494
const ThreadView = requireSlot(activeTheme, 'ThreadView')
95+
const Notice = requireSlot(activeTheme, 'Notice')
8596
const PostBit = requireSlot(activeTheme, 'PostBit')
8697
const PostActions = requireSlot(activeTheme, 'PostActions')
8798
const Pagination = requireSlot(activeTheme, 'Pagination')
8899

100+
const notice =
101+
query.replied === 'race'
102+
? 'Somebody else replied while you were writing. Your reply was posted below theirs.'
103+
: query.posted === 'moderated'
104+
? 'Your reply was posted and is waiting for a moderator to approve it.'
105+
: null
106+
89107
return (
90108
<main id="board-content" tabIndex={-1} className="flex-1">
109+
{notice !== null && (
110+
<div className="px-6 pt-6">
111+
<Notice
112+
kind="info"
113+
message={notice}
114+
dismissHref={`/thread/${thread.id}-${thread.slug}`}
115+
/>
116+
</div>
117+
)}
91118
<ThreadView
92119
{...view.view}
93120
regions={{
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { Metadata } from 'next'
2+
import { notFound } from 'next/navigation'
3+
4+
import { quotePrefill } from '@forum/threads'
5+
import { requireSlot } from '@forum/theme-kit'
6+
7+
import { ReplyForm } from '@/components/content/reply-form'
8+
import { getContainer } from '@/server/container'
9+
import { getActor } from '@/server/context'
10+
import { activeTheme } from '@/server/theme'
11+
import { buildReplyView } from '@/view/post-form'
12+
13+
export const metadata: Metadata = { title: 'Reply' }
14+
15+
function threadId(value: string): number | null {
16+
const match = /^(\d+)(?:-|$)/.exec(value)
17+
if (!match) return null
18+
const id = Number(match[1])
19+
return Number.isSafeInteger(id) && id > 0 ? id : null
20+
}
21+
22+
function quotedPostId(value: string | undefined): number | null {
23+
if (value === undefined || !/^[1-9]\d*$/.test(value)) return null
24+
const id = Number(value)
25+
return Number.isSafeInteger(id) ? id : null
26+
}
27+
28+
export default async function ReplyPage({
29+
params,
30+
searchParams,
31+
}: {
32+
params: Promise<{ slug: string }>
33+
searchParams: Promise<{ quote?: string }>
34+
}) {
35+
const [{ slug }, query] = await Promise.all([params, searchParams])
36+
const id = threadId(slug)
37+
if (id === null) notFound()
38+
39+
const actor = await getActor()
40+
const { authorizer, posts, threadWrites } = getContainer()
41+
if (threadWrites === null) notFound()
42+
43+
const target = await threadWrites.replyTarget(id)
44+
if (!target || target.visibility !== 'visible') notFound()
45+
46+
const scope = {
47+
forumId: target.forum.id,
48+
forum: await authorizer.forumMatrix(actor, target.forum.id),
49+
}
50+
if (!authorizer.can(actor, 'thread.view', scope)) notFound()
51+
if (!authorizer.can(actor, 'reply.post', scope)) notFound()
52+
53+
const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
54+
const locked = target.isLocked && !moderates
55+
56+
/*
57+
* The quote is resolved here, on the server, so quoting works with scripting
58+
* off: it is a link to this page, not a button that edits a textarea. The
59+
* quoted post is re-read through the visible-post lookup rather than trusted
60+
* from the query string — otherwise `?quote=<id>` is a way to paste any post
61+
* on the board, including one in a forum the quoter cannot see, into a forum
62+
* where everyone can.
63+
*/
64+
const quoteId = quotedPostId(query.quote)
65+
let prefill = ''
66+
if (quoteId !== null) {
67+
const quoted = await posts.findQuotable(id, quoteId)
68+
if (quoted) {
69+
prefill = quotePrefill({
70+
postId: quoted.id,
71+
authorUsername: quoted.authorUsername,
72+
message: quoted.message,
73+
})
74+
}
75+
}
76+
77+
const view = buildReplyView({
78+
thread: { id: target.threadId, title: target.title, slug: target.slug },
79+
errorMessage: locked ? 'This thread is locked.' : null,
80+
})
81+
82+
const PostForm = requireSlot(activeTheme, 'PostForm')
83+
84+
return (
85+
<main id="board-content" tabIndex={-1} className="flex-1">
86+
<PostForm
87+
{...view}
88+
regions={{
89+
form: locked ? null : (
90+
<ReplyForm
91+
threadId={target.threadId}
92+
seenLastPostId={target.lastPostId}
93+
prefill={prefill}
94+
canSubscribe={authorizer.can(actor, 'forum.subscribe', scope)}
95+
/>
96+
),
97+
toolbar: null,
98+
}}
99+
/>
100+
</main>
101+
)
102+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use client"
2+
3+
/**
4+
* The reply form (F40).
5+
*
6+
* Same shape as the composer, and for the same reasons: a client component only
7+
* for `useActionState`, a native `<form>` with JavaScript off, and no
8+
* validation the server does not repeat. The quote arrives as a prefilled
9+
* `defaultValue` from the server, so quoting works without scripting too — it
10+
* is a link to this page, not a button that edits a textarea.
11+
*/
12+
import { useActionState } from "react"
13+
14+
import { createReplyAction } from "@/server/content-actions"
15+
import { EMPTY_STATE } from "@/server/auth-form-state"
16+
17+
import { FormError, SubmitButton } from "../auth/form-controls"
18+
19+
export function ReplyForm({
20+
threadId,
21+
seenLastPostId,
22+
prefill,
23+
canSubscribe,
24+
}: {
25+
threadId: number
26+
seenLastPostId: number | null
27+
prefill: string
28+
canSubscribe: boolean
29+
}) {
30+
const [state, action] = useActionState(createReplyAction, EMPTY_STATE)
31+
32+
return (
33+
<form action={action} className="flex flex-col gap-4" noValidate>
34+
<FormError message={state.error} />
35+
{state.notice === "preview" && (
36+
<section
37+
aria-label="Preview"
38+
className="rounded-md border border-border bg-muted/40 px-3 py-2"
39+
>
40+
<h2 className="mb-1 text-sm font-medium text-muted-foreground">Preview</h2>
41+
{/* Text, not HTML: there is no renderer or sanitiser until F36. */}
42+
<p className="whitespace-pre-wrap text-sm leading-relaxed">
43+
{state.values?.message}
44+
</p>
45+
</section>
46+
)}
47+
48+
<input type="hidden" name="threadId" value={threadId} />
49+
{/*
50+
What the author had seen when this form was rendered. The action
51+
compares it with the thread's current last post to notice that somebody
52+
replied underneath them — a marker, never a lock: the reply is written
53+
either way.
54+
*/}
55+
{seenLastPostId !== null && (
56+
<input
57+
type="hidden"
58+
name="seenLastPostId"
59+
value={state.values?.seenLastPostId ?? seenLastPostId}
60+
/>
61+
)}
62+
63+
<label className="flex flex-col gap-1 text-sm">
64+
<span className="font-medium">Message</span>
65+
<textarea
66+
id="post-message"
67+
name="message"
68+
rows={12}
69+
required
70+
defaultValue={state.values?.message ?? prefill}
71+
className="rounded-md border border-border bg-background px-3 py-2 text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
72+
/>
73+
</label>
74+
75+
{canSubscribe && (
76+
<label className="flex items-center gap-2 text-sm">
77+
<input type="checkbox" name="subscribe" value="1" className="size-4" />
78+
<span>Notify me of replies</span>
79+
</label>
80+
)}
81+
82+
<div className="flex flex-wrap gap-3">
83+
<SubmitButton>Post reply</SubmitButton>
84+
<button
85+
type="submit"
86+
name="intent"
87+
value="preview"
88+
className="inline-flex h-10 items-center justify-center rounded-md border border-border px-4 text-sm font-medium transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
89+
>
90+
Preview
91+
</button>
92+
</div>
93+
</form>
94+
)
95+
}

apps/forum/src/server/container.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { CachedForumRepository, type ForumRepository } from '@forum/forums'
3636
import type { PostRepository } from '@forum/posts'
3737
import type {
3838
ReadStateRepository,
39+
ReplyWriteRepository,
3940
ThreadRepository,
4041
ThreadWriteRepository,
4142
} from '@forum/threads'
@@ -70,12 +71,16 @@ export interface Container {
7071
/** Keyset-paged thread listing (F30). */
7172
readonly threads: ThreadRepository
7273
/**
73-
* The posting write path (F39). `null` in fixture mode, which serves sample
74-
* data from memory and would lose a thread on restart — the same refusal
75-
* `FixtureForumRepository` makes for structure (D38). The composer route and
76-
* its link are absent rather than broken when this is null.
74+
* The posting write path — new threads (F39) and replies (F40). One object
75+
* because both write a post and both read the same forum flags; splitting
76+
* them would mean two adapters over the same three tables.
77+
*
78+
* `null` in fixture mode, which serves sample data from memory and would lose
79+
* a thread on restart — the same refusal `FixtureForumRepository` makes for
80+
* structure (D38). The composer and reply routes, and the links to them, are
81+
* absent rather than broken when this is null.
7782
*/
78-
readonly threadWrites: ThreadWriteRepository | null
83+
readonly threadWrites: (ThreadWriteRepository & ReplyWriteRepository) | null
7984
/** Keyset-paged visible posts (F31). */
8085
readonly posts: PostRepository
8186
/** Durable member read state. Fixture mode deliberately has none. */

0 commit comments

Comments
 (0)