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 with useProfiles(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.
  • readOnly hides the write path properly. Don’t hide the composer with CSS — agree and reply would still open a write.
  • focusPostId scrolls to and highlights a post (row id vk-post-<id>), for links from a notification.
  • React’s thinner <Comments> takes viewerpass 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’s series names the durable thread its rounds roll into; includeSeries merges 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), or subject with subjectType/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 }.

  • scope defaults to subject when you pass both subject fields, otherwise global. A subject under global or following is 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 fills post.record — label it “ranked by record”. It is never implied by room.
  • signals — a signal with attachTo is a line under that post; with attachTo: null it is its own quiet row placed before a post (null = after the last). On scope: 'subject' they are present for a taxonomy node (category, topic, org, country, a family series) and empty on a market’s or a person’s thread.
  • threads — replies folded under their parent, keyed by postId.
  • labels / titles — outcome labels and market titles for everything on the page, keyed by lowercased market id. Use them instead of fetching each market.
  • venue is a moderation overlay (that venue’s hides and bans), never a filter to that venue’s posts. room is containment — see Rooms.

The “For you” railuseForYou({ 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.

WritingusePostActions(){ 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’s posts, 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: null is 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 === false does 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: or brand: 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.
  • member is null while unknown and when the read failed — don’t draw a Join button on null.
  • 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 reads members: null, never 0.
  • room=self on 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: false for a Discussion tab. An empty Takes tab on someone else’s profile is worse than an honest fallback — check posts.length before 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, or right with oddsBeatenBps (10000 − the side’s price when the take was written; null when that price is unknown). A wrong call beats no odds. Sort by oddsBeatenBps on the client.
  • CLOB markets have no results yet. A CLOB take stays open after 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.
  • countOf is null while 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 venueId it 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” — forbidden is its own state, not an empty queue.
  • Headless: useModerationQueue({ venueId, state }){ items, nextCursor, loading, loadingMore, loadMore, forbidden, error, refresh } and useModerate({ 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 (no venueId, 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. venueId scopes 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 its strings prop (the prop wins). <CommunityTakes> takes the room slice as roomStrings.
  • connect-react’s community components (<Comments>, <Feed>, <TakeCard>, <TakePrompt>): pass strings on each component. A provider-level community override 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 on member === null.
  • Don’t print “0 takes” when countOf is null.
  • 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 400 unsupported-param. A person’s posts are GET /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.