Comments
One conversation system for anything — an article, a wiki page, a book, a product, an event — on the page, in the community as a forum thread, or embedded on somebody else's site at Disqus's own address.
People want to say something under an article, a wiki page, a book, a product and an event. That is one question, so there is one answer: a comment names a subject — a kind and an id — and nothing in the system knows what those kinds are. Five tables would be five sets of policies to get subtly different.
A thing takes comments by saying so
Off unless the file says otherwise, because a site that has not thought about moderation should not wake up to a comment section.
comments: members # off · everyone · members · hosts
comments: { allow: members, space: the-practice, channel: the-books }In the admin it is three controls, on every kind that can be commented on: Who may comment (off · anybody signed in · members · hosts) and, once somebody may, a Space and a Room — the site's own spaces, and its forum rooms only, because a stream is not a thread and cannot hold one. Leave both blank and the conversation stays on the page. Nobody types the block.
Off is the default everywhere, and off written inside the block means off: { allow: off } reads as
"takes none", the same as the bare word. It used to fall through to the default and come back as
members, so turning comments off in a block turned them on.
build/comments-push.mjs projects one row of comment_subjects per thing whose file says it takes
them — beside the events' own, which events-push writes. It is a projection and the files own it:
a subject no file declares any more is removed. Two things it never touches: the comments, and
post_id, which is the thread's identity. In local development, changing a Markdown declaration reruns the matching compile and local projection; a failed read or write stops that refresh before cleanup can remove rows.
A book is commented on a chapter at a time
"What did you think of The Kybalion" is not a conversation anybody has; "chapter four lost me" is.
So a book that takes comments gets one subject per chapter — the-kybalion/chapters/4 — and no
book-level thread at all. In the person a Comments button opens a drawer beside the page showing
that chapter's thread and no other, so somebody in the middle of reading can look, say something and
carry on from the same line. On the community's board they read as The Kybalion — 4. The Principle
of Polarity, which is a list of things people are actually talking about rather than a row of book
titles.
A reply may answer a reply; the indent is what is capped
parent_id was capped at one level, with a reason beside it: a thread that nests forever is a
thread nobody reads to the end of. That reason is about reading, and it was answered by
forbidding the reply — which is not the same thing. Somebody answering the third comment in a
run is answering that comment, and flattening it to the top loses who they were talking to.
So a comment may reply to any comment, to any depth, and the depth is kept. What is bounded is the drawing, by two numbers on the subject:
depth |
how far replies indent before the rest continue in one flat run (3 by default; 1 for the embed) |
collapse |
how many replies under one comment are shown before the rest fold behind a control saying how many (5) |
src/lib/comments/model.js works it out — pure, so the page and build/comments.test.mjs read the
same arithmetic. shape(rows, { depth, collapse, open }) returns the lines in order, each with the
indent to draw it at and, where it is drawn shallower than it really is, who it answers: once
the indent stops, the shape no longer carries that, and a flat run answering four different people
is a conversation nobody can follow.
An embed is a narrow column on somebody else's page, which is why its depth is 1: a comment, and every reply to it or to any of its replies in one run beneath, each naming the person it answers.
Where a thread sits on the page
A conversation is prose, so it takes the reading measure — --readw, the same column the
article's own text is set on, named once in atlas.css and read by both. A thread at the page's
full width does not read as the end of the piece; it reads as a band across the site that happens
to be below it. The measure is the thread's own answer and is the same on every page, which is why
it is in the component and not in each host.
How a thread is separated from whatever came before is the page's answer, not the thread's, and
is asked for with rule. The article passes it, because there the thread continues a column of
prose and the person should see it continue. A page of sections does not: the store already
separates its own, and a second hairline a few pixels under the first at a different width reads as
a mistake rather than as a division.
The gutter belongs to whichever host puts the thread at the page's own root — only the article does — since a host that already has one would double it and leave the thread indented from the column it is meant to continue. That is what happened on the event page the first time.
bare is the person's panel and the iframe embed. Each is already a column, so it takes neither
measure nor rule: a column inside a column is an indent nobody asked for.
Whether what is said appears at once
A subject carries an approval setting:
none |
everything appears at once — the default |
all |
everything waits for a person |
trusted |
everything waits except from somebody the site already trusts |
Trusted is a standing the site can already answer, true before anybody typed anything: staff, a
live membership, a paid purchase, or an author a moderator has released before. That last one is
the approval stamp on the comment that was released (comments.approved_at) and not a flag on the
profile — one fact, one place, nothing to fall out of step.
A held comment is visible to its author, marked as waiting, and to nobody else but whoever may moderate it. A comment that vanishes on posting is how somebody decides the site is broken and says the same thing twice.
The practical difference from a spam filter: a filter decides a comment is bad and is sometimes wrong about a real person, invisibly. This decides a person is not yet known and shows their comment to a moderator, who decides.
Flagged words: a machine may flag, only a person decides
The site may declare words that hold a comment for review — profanity, slurs, a competitor's spam, whatever that community has decided it does not want appearing unread. A match holds the comment and names the word to the moderator, who can disagree with the list. It never removes, refuses, edits or scores anything: the line is between queueing work for a person and being the person.
Three details decide whether the filter is useful or a nuisance:
- Whole words, case-insensitively, never substrings. Substring matching is how "Scunthorpe" and "assassin" get held, and a filter that cries wolf is one a moderator stops reading.
- The list is the site's own, not one the platform ships. What a community will not have said is that community's business.
- A flag beats trust, because the list exists for when trust is misplaced — and the author is
never told which word caught them: a list you can probe is a list you can walk around. The list
lives in
app_settings, which no browser can read, andcomment_flagged_wordis granted to the service role alone.
comment_approve and comment_refuse are the moderator's two acts, and each writes a row of
space_moderation like any other removal — a person's name, a moment, a reason where one is given,
and a record nobody can edit. It is the only act that happens before anybody else reads the
words, which is exactly why it is written down.
Votes and reactions
A reaction is expressive — an emoji, several per person. A vote is a judgement — up or down,
one per person. Both are answered for a comment and for a message in a room by one pair of tables
(reactions, votes, keyed by surface, target and person) and one rule for who may cast one, so
"who may vote" is written once. See Votes and reactions.
Every surface says for itself which of them it takes. Reactions on, votes off is the default everywhere, and a comment subject and a channel are set apart — wanting a scored forum says nothing about wanting scored book chapters.
Who may read, and who may say — two questions
A members-only article may take comments from members; a public one from anybody signed in; a book
may show them and take none. Both live on the projected row (access and allow), so one policy
answers for every kind:
- read —
comment_may_read: the subject'saccess, on the same ladder the rest of the library is gated by. - write —
comment_may_write:off·hosts(staff) ·everyone(anybody signed in) ·members(whoever may see it).
One conversation, two doors
Where a subject names a forum channel, its comments are a post in that channel. The first
comment opens the post — so the board is not full of empty posts for every book in the library —
and after that the page and the board are two views of one thread. Say something on the book's page
and it is in #the-books; say it in #the-books and it is on the book's page.
The moderation comes free with it. A comment is a space message, so reports, removals and the moderation queue already cover it, and the desk needs no second set of tools for a second kind of talk.
A subject with no channel keeps its comments in public.comments. That is what a site with no
community wants, and what an embed on somebody else's domain gets.
On the page
CommentThread.vue takes a kind and a subject and nothing else — which of the two places the thread
lives in is the database's business, and a component that had to know could get it wrong. It asks
comment_subject once for what the conversation is (does it exist, may I join it, where else can it
be read) and draws nothing at all where the thing takes no comments: an empty "Comments" heading
under every article on a site that never wanted them is worse than none.
Where a comment is answered for
A comment whose subject names a forum room is a post in that room: it is moderated there, by whoever moderates it, with the reports and removals the community already has. One attached to no room — a site with no community, an embed on somebody else's domain — has no room's moderators, and without somewhere to answer for it, it would be a conversation nobody is responsible for.
So there are two surfaces, and they are not the same list:
- The desk's Comments queue (
src/lib/desk/comments.js) — the unattached ones that are waiting to be decided, in the staff's one list beside the support desk and the clients' threads. The source is a file and a line, so it needed no change to the desk itself. - The admin's Comments section (
/admin/comments) — every comment on the site, narrowed by state (waiting · live · removed), by where it is, and by kind.
Both offer Publish it and Refuse it by name. Neither uses the desk's tick for it, and neither has a control that edits somebody else's words: a moderator publishing something happens before anybody else has read it, and rewriting what a person said is the one act this system does not have.
Votes and reactions in a room
A channel carries reactions and votes of its own; a space sets the default its rooms inherit,
and the projection resolves the ladder so the row holds the answer rather than the question. A room
that says nothing takes reactions and not votes, like everything else.
# content/spaces/the-practice/the-books.md
name: The books
votes: true # this room scores its posts; the others do notA comment subject is set apart from this and inherits nothing from it, because wanting a scored forum says nothing about wanting scored book chapters.
A kind may be open-ended
Everything of the site's own is projected, because the site knows what it has. A page on somebody else's site is not: an embed on a blog with two hundred posts cannot have two hundred rows projected for it, and Disqus makes a thread the first time anybody comments.
So content/comments.md declares which kinds are open:
kinds:
- kind: page
allow: members
space: the-practice
channel: the-booksThat writes one wildcard row per kind (subject: '*') carrying the access, the allow and the
channel. A concrete subject is made the first time somebody comments, from that row, named by the
title the embed passed — this.page.title, or the document's. It is still a declaration: the site
says the kind is open, and nobody can invent a kind. A site that declares none can be embedded
nowhere, which is the right default.
A caller decides three things and nothing else — and it may NAME a thread, never rename one:
| slug | its identity, and so the subject | /content/articles/listening-back |
| title | what a person reads, in the board and in the queue | Listening Back |
| url | where the page actually is, shown as the way back | https://example.com/blog/listening-back |
Two pages with the same slug are one conversation, and a page that changes its address keeps its comments. Declare none and the slug is the page's path and the title its document title, so a snippet pasted with no configuration still works.
Embedded on somebody else's site
At Disqus's own address and in Disqus's own shape, because answering a snippet people already
have in their pages means nobody has to learn anything to try it. Change one line — the src — and
the conversation moves here:
<div id="disqus_thread"></div>
<script>
var disqus_config = function () {
this.page.identifier = '/content/articles/listening-back' // the slug, and so the thread
this.page.title = 'Listening Back' // what a person reads
this.page.url = 'https://their-site/blog/listening-back' // the way back
}
var disqus_kind = 'page' // ours: which kind of thing this is
;(function () {
var d = document, s = d.createElement('script')
s.src = 'https://<this-site>/packs/js/comments.js'
;(d.head || d.body).appendChild(s)
})()
</script>It is a frame, and that is the whole design
The script draws an iframe of a page of ours (/embed/comments) and sizes it from what that
page reports. It used to mount the thread into the host page, and that could only ever be read:
a script in somebody else's page runs on their origin, and the person's session lives on ours —
so everybody in it was a guest, and a guest may not comment. A thread nobody can join is half a
comment system.
Inside a frame of ours the session is ours to read, the sign-in is ours to offer, and every policy behaves exactly as it does on the site. Two things follow that are worth having for their own sake: nothing of ours runs in the host page — no Vue, no stylesheet, no client library, just a few hundred bytes and an iframe — and the frame carries no background of its own, so it sits in their page rather than painting over it.
A browser partitions storage by the top-level site, so signing in inside the frame signs somebody in for that site and no other. That is right rather than a shortcoming: somebody who signed in under one publisher's article has not signed in everywhere the embed appears.
Or write the frame yourself
It is a URL, so nobody has to run a script at all:
<iframe src="https://<this-site>/embed/comments?kind=page&subject=/a-post&title=A%20post"
style="width:100%;border:0" height="600" loading="lazy"></iframe>kind |
which kind of thing this is; the site must have declared it open |
subject |
the slug, and so the thread's identity |
title |
what a person reads, in the board and in the desk's queue |
url |
where the page actually is, kept as the way back |
The frame says how tall it has become on every change (postMessage, { platformkitComments: true, height }), and grows while the sign-in is open — a modal mostly below the fold of a 200-pixel frame
is a sign-in nobody can reach, which is the one thing the frame exists to offer. Size it from that
message, or give it a fixed height and let it scroll.
Where it may appear
content/comments.md may name the domains the embed belongs under (Community → Comments → Policy):
domains: [example.com, thepartnersite.org]A domain covers its subdomains, because somebody writing example.com is not thinking about www.
Name none and the embed works anywhere, since a site that has not decided has not decided
against.
Two things this is honest about. It is not a secret: a determined person can always comment by
visiting the site itself, and what a whitelist prevents is the site's comments appearing under pages
it did not agree to. And the in-page check gives a readable refusal rather than a blank one — the
real browser-level fence is a frame-ancestors header, which the host serves:
Content-Security-Policy: frame-ancestors 'self' https://example.com https://*.example.comon /embed/*. Set both: the header stops the frame loading at all, the list in the policy can be
changed without a deploy and says why in words.
Not honoured, and it says so in the console rather than pretending: the Disqus shortname (this site is the forum, so there is nothing to name), SSO, and the moderation API.
The .disqus-comment-count spans still work and are not framed — a number inside somebody's
sentence cannot be. They are a plain read of a public count.