Votes and reactions
Two ways of answering something without writing a reply — an emoji and a judgement — for a comment and for a message in a room, from one pair of tables and one rule.
A reaction is expressive: an emoji, several per person, saying how something landed. A vote is a judgement: up or down, one per person, saying whether it was worth reading. They are not the same act and they do not have the same arithmetic, so they are two tables and not one with a nullable column pretending otherwise.
They ask the same two questions of a comment and of a message in a room, so each is one
table keyed by (surface, target, person) rather than a pair per surface — and the rule for who
may cast one is a single function taking the surface and the id, so the sentence "who may vote"
exists once. Where the two surfaces must differ they differ in what is passed to the rule.
reactions (surface, target_id, user_id, emoji) surface: comment | message
votes (surface, target_id, user_id, vote) vote: +1 | -1reactions is space_reactions grown a surface: every emoji anybody has left in a room is still
there, under surface: message.
Two defaults, both deliberate
Reactions on, votes off. A score changes what people write as well as what they read, so a site opts into that rather than finding it already running.
A comment subject and a channel are set apart, and neither inherits from the other: a site may
want voting on its forum and not under its books, or the reverse. A channel carries reactions and
votes of its own (its space sets the default its rooms inherit, and the projection resolves it, so
the database holds the answer rather than the question); a comment subject carries the same two,
from the site's comment policy.
signal_takes(surface, target, what) is that question, and signal_may(surface, target, what) is
whether this person may — which is the same standing as saying something there. Somebody who may not
comment may not vote; somebody who cannot read a channel cannot react in it; nobody with no account
can do either, because a vote from nobody is not a signal.
Changing your mind is the same row
Casting the opposite replaces a vote; casting the same again withdraws it. One row per
person, never two, and never a count that drifts. voteAfter(had, pressed) is that rule, and
voteTally(comment, next) is what the page should show the instant somebody presses, before the
write comes back.
The person chooses the order; the surface decides whether there is one
Oldest first stays the default, so turning voting on changes nothing about how a page reads until somebody asks it to. Where a surface takes votes, somebody may choose:
| order | what it does |
|---|---|
| oldest | as written — a conversation read in the order it happened |
| newest | most recent first |
| top | the highest score: ups less downs |
| best | the most confidently liked |
| controversial | the most argued over: many votes, close to even |
Where a surface takes no votes only oldest and newest are offered: an order nothing can compute is not a choice. The person's own order is theirs, is remembered, and never changes what anybody else sees.
best is not top
best is a confidence bound — Wilson's lower bound on the proportion of ups — and not ups less
downs. A comment 9 up and 1 down beats one 40 up and 30 down, because the second is disputed
and the first is not, while top puts them the other way round. A raw score rewards whatever was
seen most, and the difference between the two orders is the whole reason both exist: a system with
only one of them is a system where the loudest thread wins, which is the thing sorting was supposed
to fix. build/reactions.test.mjs pins it on exactly those numbers.
controversial needs both halves: two people disagreeing is not a controversy, and forty people
agreeing is not one either.
A vote is a signal, and nothing is decided by arithmetic
A tally is recorded and readable, and nothing is ordered by it unless the surface says so. What a community rewards is a decision about that community, and a platform that sorts by score everywhere has made it for them.
A downvote hides nothing. A comment below any number is still there and still readable; removing something is an act of moderation with a person's name on it.
The tallies come back with the lines
comments_on returns, with each comment, how many of each reaction, which of them are this
reader's, the two vote tallies and this person's own vote — aggregated, two grouped scans over
the ids in hand, not a query per line. A thread of two hundred comments asking two hundred times is
the thing that makes a comment system feel slow, and it is invisible until it is.
The arithmetic over what comes back is pure (src/lib/comments/model.js): reactionsOf turns the
tally into rows most-first saying whether this person is among them, and sortComments applies the
order the person chose — over the roots alone, because a reply belongs to what it answers and
sorting the whole flat list would scatter every conversation.