Comments & feeds
Takes, comments, rooms and moderation — which component goes on which screen, what each hook returns, and the server rules a venue must not work around. One store of posts; the prediction is the cheap action and the comment is its optional why.
Everything social in a venue — a market’s comment thread, a home feed of takes, a room, a profile’s receipts, a moderation queue — reads and writes one store of posts. This page is the map: the model first, then which piece to put on which screen, then the rules the server enforces so you don’t build around them.
The model: a take is a comment with a stance
- A post is written on a subject — a market (
event), or a taxonomy node (category,topic,series,country). A market post is keyed('event', conditionId), the same thread in every venue. - If the author held a position on that market when they wrote it, the server attaches a stance: the side, the size, and the market price of that side at that moment (“backed YES · 23¢”). The client never sends a stance — the server reads the position and freezes it. Editing a post never changes its stance.
- A post with a stance is a take. A post without one is just a comment. Same row, same store; the difference is only whether a scored position stands behind it.
- The prediction is the cheap action; the comment is the optional why. The market page’s job is the trade, so its comments are deliberately thin. The feed is where takes are the product.
- A single trade is never a feed row. Trading reaches a feed as signals (“3 ranked callers moved to NO”) attached to the page, not as posts.
- Identity is referenced, not embedded. A post carries
author(a wallet); hydrate names and avatars withuseProfiles(wallets)once per page.
Which piece goes where
| Screen | Use | Package |
|---|---|---|
| Market page | <MarketComments marketId> — or <Comments subjectType="event" subjectRef={conditionId}> |
venue-kit / react |
| Home, community tab | <CommunityTakes> — or useFeed + useForYou for your own layout |
venue-kit / react |
| A room (your venue’s discussion) | <RoomFeed roomId={yourRoomId}> |
venue-kit |
| A venue section that is a taxonomy node | <RoomFeed subject={{ subjectType: 'category', subjectRef: 'crypto' }}> |
venue-kit |
| Market cards in a grid | useTakeCounts(allIdsOnTheGrid) |
react |
| A profile | useAuthorTakes(wallet) + takeReceipt |
react |
| After a buy, outside a room | <TakePrompt> under <ProphecyProvider> |
react |
| Operator screen | <ModerationQueue venueId> — or useModerationQueue + useModerate |
venue-kit / react |
venue-kit’s components are composed screens (layout, strings, empty states). connect-react’s are the building blocks under them. Start with venue-kit; drop to react when you need your own markup.
Market page — comments
<MarketComments marketId={market.id} onRequireSignIn={login} />
- One market’s thread, newest first, one level of replies, a composer, load-more.
readOnlyhides the write path properly. Don’t hide the composer with CSS — agree and reply would still open a write.focusPostIdscrolls to and highlights a post (row idvk-post-<id>), for links from a notification.- React’s thinner
<Comments>takesviewer— pass the signed-in wallet or no edit/delete controls are drawn, because guessing puts a Delete button on somebody else’s writing. - Headless:
useComments(subjectType, subjectRef, { includeReplies, includeSeries, limit, venue })→{ posts, threads, series, loading, refreshing, error, loadMore, hasMore }. A recurring market’sseriesnames the durable thread its rounds roll into;includeSeriesmerges it in — label those rows, their stances are about a different round.
Home — the feed of takes
<CommunityTakes scope="global" includePost={isOurMarket} emptyFallback="global" />
scope:global(default),following(needs a session), orsubjectwithsubjectType/subjectRef.- Without
includePost/includeSignal, a venue shows every take on Prophecy under its own heading. Narrow to your markets. emptyFallback="global"falls back to everyone’s takes (with a notice) when a following or subject scope is empty.- A reply written here reads on the market page, not on this surface.
Headless: useFeed(scope, { subjectType, subjectRef, venue, room, sort, limit }) →
{ posts, signals, settlements, threads, labels, titles, loading, error }.
scopedefaults tosubjectwhen you pass both subject fields, otherwiseglobal. A subject underglobalorfollowingis ignored by the server — the hook warns.- The page is ranked on the server. Render it in order; don’t re-sort.
sort: 'live'(default) is argument-first;sort: 'record'orders by each author’s settled record at the prediction’s node and fillspost.record— label it “ranked by record”. It is never implied byroom. signals— a signal withattachTois a line under that post; withattachTo: nullit is its own quiet row placedbeforea post (null= after the last). Onscope: 'subject'they are present for a taxonomy node (category,topic,org,country, a familyseries) and empty on a market’s or a person’s thread.threads— replies folded under their parent, keyed bypostId.labels/titles— outcome labels and market titles for everything on the page, keyed by lowercased market id. Use them instead of fetching each market.venueis a moderation overlay (that venue’s hides and bans), never a filter to that venue’s posts.roomis containment — see Rooms.
The “For you” rail — useForYou({ room, subjectType, subjectRef, limit }) →
{ items, markets, personalized }. Each item carries its reason. When personalized is false, show
one hint line offering the personal version — never a sign-in prompt per card.
Hand-built UI — <Feed> renders a ranked page of <TakeCard>s (posts only; it ignores signals
and settlements). <TakeCard> draws the <StanceChip>, Agree (optimistic, rolls back) and Reply;
edit/delete only when you pass viewer and it is the author’s own post; Report only on
someone else’s post, signed in, with moderation on; Tail only with a checkout provider above it
or an onTail. useTail().tail(post) opens the checkout on the take’s exact side and returns
false rather than guessing when it can’t.
Writing — usePostActions() → { create, edit, remove, react, report, pending }. create(subjectType, subjectRef, body, parentId?) sends no stance; the server proves it. Every action throws on failure.
useCanPost() reads the client’s session, not the wallet — hand one ConnectAuth to both providers
or it stays false.
Rooms
A room is a venue’s own discussion: its members, its board, its thread.
<RoomFeed roomId={roomId} onSignIn={login} onOpenMarket={openMarket} />
<RoomFeed> is the whole room: the discussion (ranked by record by default), a closing-soon rail of
the room’s markets, the For-you rail, the take prompt after a buy, and a Join card for a signed-in
reader who isn’t a member. membership={false} turns the card off; takePrompt={false} the prompt.
Rows get a Tail control only when you pass onTail.
Headless:
useRoomFeed(roomIdOrSubject, { sort, limit, marketLimit })→ the room’sposts,markets,signals,settlements,threads,forYou.useRoomMembership(room = 'self')→{ member, members, joinedAt, loading, readError, pending, refused, error, join, leave }.useRoomBoard(room = 'self')→{ board, notFound }— members ranked at the room’s node, as an ordinal with a denominator (“#3 of 212”).rank: nullis unranked, not last.
The rules, enforced on the server:
- Membership is an invitation, not a posting gate you check. Posting into a room is allowed for a
member or anyone who follows the room’s subject (their membership is recorded on the first
post). So
member === falsedoes not mean “may not post” — never hide a composer on it. The API refuses a post it won’t take, with a 403: “join this room to post in it”. - Following only admits on a followable subject. A room whose subject is an
org:orbrand:node has no follow path — its members join explicitly. - Leaving is sticky: someone who left is not re-admitted by following, and their post is refused with a different message — “you left this room — rejoin to post”. Handle both.
memberisnullwhile unknown and when the read failed — don’t draw a Join button onnull.- Join and leave work only for your own venue’s room (the session’s venue), which is why
'self'is the default. Joining another venue’s room is an error, not “invite-only”. - Private rooms are invite-only. A join answers
refused(“this room is not open to join”). To an outsider a private room’s thread and board answer 404, not 403 — a 403 would confirm the room exists. Its headcount readsmembers: null, never0. room=selfon a read means “the room of the venue whose API key sent this request”. It needs a venue key.
Profiles — a person’s takes
const { posts, results, hasMore, loadMore } = useAuthorTakes(wallet)
const receipt = takeReceipt(post, results)
- Defaults to the receipts page: takes only, no replies. Pass
takes: falsefor a Discussion tab. An empty Takes tab on someone else’s profile is worse than an honest fallback — checkposts.lengthbefore choosing the default tab. - Any wallet the person owns gives the same list: posts belong to the account.
results— how each resolved market on the page ended, keyed by market id, as the chain recorded it. An unresolved market is absent.takeReceipt(post, results)→null(no stance),open,void,wrong, orrightwithoddsBeatenBps(10000 −the side’s price when the take was written;nullwhen that price is unknown). A wrong call beats no odds. Sort byoddsBeatenBpson the client.- CLOB markets have no results yet. A CLOB take stays
openafter its market resolves — don’t render that as a confident “still open”.
Profile identity: useProfile(wallet), useProfiles(wallets), useMe(), <FollowButton>,
useFollow(subjectType, subjectRef) — bump its refreshKey on login and logout, or a latched
“Follow” can silently unfollow.
Market cards — take counts
const { countOf } = useTakeCounts(cards.map((c) => c.id))
const c = countOf(card.id) // { posts, takes, bySide } | null
- Pass every market on the grid at once: one request covers up to 100, and the sdk splits more. The same set of ids in any order or casing shares one cached read.
countOfisnullwhile loading or after a failed read. Print nothing — never “0 takes” for a market you haven’t heard about. Once loaded, a market with no posts is zeros.- The counts are the same for every reader: visible posts in public rooms and unroomed threads. A private room’s discussion is left out, because a public card must not reveal that a private room is discussing a market. That is why a card’s count can differ from a room’s “3 here, 24 elsewhere”.
After a buy — the take prompt
<TakePrompt outcomeLabel={labelOf}> (or useTakePrompt()) asks for the reason right after a side is
taken. Mount it inside <ProphecyProvider>. <RoomFeed> renders one for you — but only when a
<ProphecyProvider> is above it; without one there is no prompt, and nothing warns.
- Opens on a settled buy from the checkout drawer. A silent 1-tap send never opens it — that path is silent by design.
- Once per wallet, market and side. Skip or a posted take counts as answered and is remembered across reloads; buying the other side, or another market, asks again. A prompt that opened but was never answered asks again after a reload.
- Skipping is honest: the position still counts, only the argument is missing. Never make the take required.
Moderation
<ModerationQueue venueId={venueId} onOpenPost={openPost} />
- The venue owner’s queue of reported posts — including ones the report threshold already hid —
with Open / Actioned tabs and keep, hide, ban and unban. Without
venueIdit is the Connect-level operator queue: Hide removes everywhere and Ban bans everywhere. A reader who can’t moderate the venue sees “you can’t moderate this venue” —forbiddenis its own state, not an empty queue. - Headless:
useModerationQueue({ venueId, state })→{ items, nextCursor, loading, loadingMore, loadMore, forbidden, error, refresh }anduseModerate({ venueId })→{ keep, hide, pin, ban, unban, pending, error }. Writes are not optimistic. - Two levers, two blast radii. A venue hide (with
venueId) hides a post on that venue only; it stays live everywhere else. A Connect-level removal (novenueId, operator only) removes it everywhere.venue=on a read applies that venue’s hides and bans; it never partitions the store. - A ban blocks posting only — never reading or trading.
venueIdscopes it; none is global (operator only). - Readers report with
usePostActions().report(postId, reason). Enough reports hide a post automatically pending review. - A hidden post is still visible to its author on the market thread and their profile — so a spammer gets no signal to iterate against. The ranked feed shows visible posts only.
Strings
- venue-kit: override per slice with
<VenueKitStringsProvider value={{ comments: {…}, room: {…}, moderation: {…} }}>, or per component with itsstringsprop (the prop wins).<CommunityTakes>takes the room slice asroomStrings. - connect-react’s community components (
<Comments>,<Feed>,<TakeCard>,<TakePrompt>): passstringson each component. A provider-levelcommunityoverride does not reach them.
Traps, in one list
- Don’t re-sort a ranked feed.
- Don’t send a stance, and don’t render one you computed — use
post.stance. - Don’t hide a room composer on
member === false; don’t draw Join onmember === null. - Don’t print “0 takes” when
countOfisnull. - Don’t treat a CLOB take with no result as settled-open.
- Don’t hide the market composer with CSS — use
readOnly. - Don’t pass extra query parameters to
GET /v1/social/posts/feed: anything not in its contract is a 400unsupported-param. A person’s posts areGET /v1/social/profile/:wallet/posts. - Don’t mount two
ConnectAuths — posting and reacting need the one session both providers share.
The sdk, for non-React venues
Everything above is client.social in @prophecy-dev/connect-sdk:
posts(subjectType, subjectRef, { includeReplies, includeSeries, limit, cursor, venue, room }) |
one thread, newest first, cursor-paged |
postFeed({ scope, subjectType, subjectRef, venue, room, sort, limit }) |
the ranked feed |
postsByAuthor(wallet, { takes, includeReplies, venue, limit, cursor }) |
a person’s posts, with results |
takeCounts(marketIds, { venue }) |
counts for a grid; public, no session |
forYou({ room, subjectType, subjectRef, limit }) |
the discovery rail |
createPost, editPost, deletePost, reactPost, reportPost, block, unblock |
writes (session required) |
rooms.membership, rooms.board, rooms.join, rooms.leave |
rooms, room defaults to 'self' |
mod.queue, mod.keep, mod.hide, mod.pin, mod.clear, mod.ban, mod.unban, mod.remove, mod.restore |
moderation (gateway-gated) |
onComments(subjectType, subjectRef, cb) |
live: fires on change, carries no payload — refetch |
Reads work signed out and send the session only when there is one.