How the hub's per-member post feeds work: the room shape, the custom event, how a feed is
discovered, and — the part that drives everything else — how privacy is actually enforced.
Read this before touching apps/web/src/matrix/feed.ts,
apps/web/src/matrix/hooks/useSpaceFeed.ts, or apps/web/src/features/feed/.
The obvious way to add microblogging to a Matrix app is to run a fediverse server next to it. That buys a second server to operate, a second account identity for every member to understand, an ActivityPub inbox/outbox to secure, and a second moderation queue — to get something Matrix already supports. Rooms, timelines, rich text, reactions, redaction, edits, uploads and permissions are all here already. What was missing was a room shape and a feed view, not a protocol.
Matrix has no per-event visibility. m.room.history_visibility is a property of the room,
not of an event in it. So a "private": true field on a post in a readable room would be
decoration: the event is still in /messages for anyone who can read the room.
Privacy here is therefore decided by where a post lives, never by a field on it:
| Where it lives | Who can read it | |
|---|---|---|
| Public post | an xyz.nekous.post event in the author's feed room |
anyone in the Space |
| Private post | the author's own account data (xyz.nekous.private_posts) |
only the author |
Making a public post private redacts the event and writes its text back to account data;
publishing a private one does the reverse. Both orderings are deliberate (feed.ts): unpublish
redacts first, so a failure leaves the post gone rather than visible; publish sends first, so
a failure leaves the text still saved rather than lost. The post gets a new event ID either way,
so reactions don't survive the move — the honest outcome, since its audience changed.
{
"body": "finally got the **voice channels** working",
"format": "org.matrix.custom.html",
"formatted_body": "finally got the <strong>voice channels</strong> working"
}A timeline event with its own type rather than an m.room.message, for two reasons:
- It can be power-level gated on its own. A feed room sets
events: { "xyz.nekous.post": 100 }, so only the owner posts while everyone else keeps the default level and can still react.m.room.messagecan't express that — a threaded reply and a top-level post are the same event type, so gating one gates the other. - A feed room peeked from Element shows nothing, rather than a chat log that isn't one.
The content is message-shaped on purpose, so renderMessageText and buildMessageFormatting
work on it completely unchanged — posts get the same emotes and inline Markdown as messages,
from the same code. Mentions are the exception (see "Not in this pass").
A useful consequence of the custom type: posts notify nowhere. Matrix's default push rules match
m.room.message and m.room.encrypted and nothing else, so an unknown event type produces no
notification and no unread count; DesktopNotifications and MentionInboxCollector both filter
on m.room.message as well. Joining one feed room per member therefore doesn't turn the hub into
a notification firehose — which it otherwise would, since those rooms are joined in bulk.
One per member per Space, created on their first post (ensureFeedRoom). Not created through
roomCreation.ts's createRoom: a feed room isn't a channel and wants none of that function's
policy, so bending it to fit would push feed-shaped options into every channel's creation path.
m.room.join_rules |
restricted, allowing m.room_membership of the Space |
m.room.history_visibility |
world_readable for a public Space; shared (members only) for any other — see "Private Spaces" below |
xyz.nekous.channel_type |
feed |
xyz.nekous.feed |
{ owner, spaceId } |
| power levels | events: { "xyz.nekous.post": 100 }; events_default untouched; then kept in step with the Space (see "The Space's authority over feeds") |
| directory visibility | private — a feed is reached through the Space, not by searching |
The owner is power level 100 as the room's creator, so "I can delete my own posts and no ordinary
member can" needs no moderation code at all. Restricting the join rule to the Space is what
lets other members join to read and react without the owner inviting each of them by hand — the
same mechanism voice channels use (docs/voice-architecture.md).
A feed room isn't a Space child, and only its owner has power in it, so nothing the Space does reaches it by itself. Left alone, that meant a member banned from a Space stayed joined to every feed they had opened (still reading a private Space's posts, still commenting), and a Space moderator couldn't remove an abusive post or comment.
Only the owner can change their feed room, so the owner's client keeps each of their feeds in
line with its Space (matrix/feedGovernance.ts, run by FeedGovernance in AppShell at start and
whenever the Space's members, power levels or visibility change):
- Membership. Anyone joined to the feed who isn't joined to the Space is kicked. A kick is enough: the restricted join rule only lets Space members back in.
- Moderators. Whoever can delete messages in the Space (at or above its
redactlevel, plus its creators on room version 12) is power level 50 in the feed. That's enough to delete posts and comments and to kick, not enough to post (100). Every state event in the feed is raised to 100 (state_defaultand each named type), so a moderator can't change the feed's visibility, join rule or feed marker. Someone who stops moderating the Space is taken back out. - Visibility. History visibility follows the Space being listed or not (see "If a Space changes"). A failed directory lookup changes nothing, rather than flipping the feed on a blip.
This runs only while the owner has the app open. A ban lands in someone's feed when that owner
is next online. Until then the reading side covers it: comments from anyone the Space says has
left or been banned are hidden (isRemovedFromSpace). That's decided only on a definite answer,
since a member who hasn't been lazy-loaded yet isn't one who left. Likes aren't filtered; a like
count can't say whose likes it counted.
Reporting. Any post or comment by someone else has a Report action, which sends it to the
homeserver's admins (POST /rooms/{roomId}/report/{eventId}). It doesn't go to Space moderators,
who can already remove it themselves. Continuwuity refuses a report from outside the room ("You
are not in the room you are reporting", checked live), so reporting joins the feed room first,
the same way liking does. Only a Space's members can join its feeds, so only they can report there.
Checked against Continuwuity (room version 12, its default): the owner rewrites the feed's power levels without listing themselves (v12 creators mustn't be listed), a moderator at 50 can remove a post but can't post (403) or change the room's state (403), and someone kicked after leaving the Space can't rejoin (403).
Voice calls end with membership. Leaving a voice channel's room (by leaving its Space, or being kicked) hangs up the call. The LiveKit token was issued while you were a member, so nothing else would.
A member's feed room ID is published as a custom key on their own m.room.member event in the
Space (xyz.nekous.feed_room). This is the one piece of plumbing worth understanding, because
the obvious alternatives both fail:
m.space.childneeds state permission in the Space, which ordinary members don't have — and handing it out is precisely the hole the voice tenancy gate closes, since a member who can add Space children can add a room of their own and claim voice service for it.- Account data is private to its owner, so nobody else could find the feed.
Your own member event is the one piece of Space state you can always write and everyone can
always read. It's already synced, so listing every feed in the hub costs zero requests.
nicknames.ts leans on the same property for per-Space display names — which is also why
publishFeedPointer spreads the existing content rather than replacing it.
Because member-event content doesn't survive leaving and rejoining a Space, the author also keeps
their own copy in account data (xyz.nekous.feed_rooms), checked first, so a rejoin re-points at
the existing feed instead of silently starting a second one.
useSpaceFeed merges the live timelines of every feed in the Space, newest first —
mentionInbox.ts already merges across rooms the same way, and at hub scale sorting the union
costs nothing worth optimizing. Only posts sent by a feed's owner count; power levels already
prevent anyone else posting there, so that check only matters for a room whose levels were
hand-edited.
Pagination goes wide, not deep. Every feed is paginated together, one page each, rather than
whichever room happens to hold the oldest post — the view is sorted by timestamp, so fetching
one room at a time would let a newer post from another feed appear below an older one that had
already been loaded. A room with nothing older left is skipped; one that errors is skipped rather
than failing the pass. The first load also paginates (up to three passes, or until it has ten
posts) because a freshly joined room arrives with only whatever /sync chose to include, which
for a quiet feed can be nothing at all — without that the hub looks empty in exactly the case
where it should look fullest.
Opening the view first calls followSpaceFeeds, which joins any feed room this client isn't
in yet. (SpaceAutoJoiner also does this in the background, so mentions reach people; see
"Mentions".) A private Space's feed rooms can only be read by joining; for a public Space's,
joining rather than peeking is a deliberate trade: reading a world_readable room you
aren't in requires a peek, which is the least reliably supported corner of the client-server API.
The cost is that a member ends up joined to one room per other member — fine at hub scale,
and the reason useSpacelessRooms filters feed rooms out (without it, every member's timeline
would appear as a group chat in Direct Messages).
A post carries up to four images or videos under xyz.nekous.attachments (matrix/postMedia.ts):
JPG, PNG, GIF, WebP, WebM and MP4. How each is stored depends on where the post goes (see "Private
Spaces"): a plain mxc:// upload for public places, an encrypted one everywhere else.
Smaller uploads. JPEG and PNG are re-encoded to WebP in the browser before upload (quality
0.85, longest edge capped at 2560px). The original is kept if the WebP isn't smaller or the
browser can't encode WebP. GIF, WebP, WebM and MP4 upload untouched: a canvas encodes one still
frame, so converting an animation would freeze it. The composer shows how much was saved, and
checks the server's m.upload.size before uploading.
Each attachment records its width and height, so the post reserves its space before the media
loads. readAttachments drops anything malformed (a non-mxc URL, an unsupported type, more than
four) rather than rendering it.
Also on WATCH. A post with an MP4 or WebM that goes somewhere public (the global feed or a
profile, or a public Space: the same plain uploads) offers Also add to WATCH, on by default.
Never for encrypted media. The video is then published as an xyz.nekous.video event reusing the
post's mxc URL (docs/watch.md, "The app"); the post doesn't depend on it.
A Space that isn't public (not listed in the directory) keeps its posts, and their media, to its members.
The posts. A private Space's feed rooms use history visibility shared: readable only by
someone who has joined the feed room, and the restricted join rule only lets members of the Space
join. Someone with an account but not in the Space can't read them and can't join them. (Checked
against Continuwuity: an outsider's /messages comes back empty and their join is refused with
403; a member joins and reads.) shared rather than joined means someone who joins the Space
later can still read what was posted before they arrived.
The media. Matrix media isn't access-controlled per room: an mxc:// upload can be
downloaded by anyone who has its URL, with any account on a server that requires authenticated
media and with no account at all on one that doesn't. So media in a private Space's posts, and
in "Only me" posts, is encrypted in the browser before upload. This is the same
encrypted-attachment format and library the chat timeline uses (browser-encrypt-attachment).
The ciphertext is uploaded with no filename and as application/octet-stream. The key, IV and
hash travel inside the post (file instead of url on the attachment), and only people who can
read the post have them. Anyone else who gets the URL downloads noise.
Public or private is decided when posting, from the directory. The composer waits until the directory has answered before it will post into a Space. If the lookup fails, the Space counts as private, which is the safe direction.
If a Space changes. A Space can be made public or private after its feeds exist. Each author's
client brings their feed room's visibility in line as soon as it sees the change, and again at
every start (FeedGovernance, see "The Space's authority over feeds"). Posting checks it too.
What this can't do for posts made earlier. History visibility applies to events from the moment it's set. Posts made in a private Space before this change (when every feed room was world-readable) stay readable by anyone who has the feed room's ID, and their media stays unencrypted at its URL. To take one back, delete it (or use "Make private"). Deleting the post removes the only link to its media, but the file itself stays on the media server until a server admin purges it. Matrix gives clients no way to delete an upload.
The server admin can still read it. Members-only rooms and encrypted media stop other users.
They don't stop whoever runs the homeserver, who can read room history (the key is inside it).
Hiding posts from the server too would mean end-to-end encrypting the feed rooms themselves.
That's a larger change: the global feed and profiles read posts over plain /messages, which
can't decrypt.
Picking Global as a post's destination sends it to the author's profile feed
(matrix/profileFeed.ts), created on their first global post. It's a feed room with no Space:
- Listed in the directory under room type
xyz.nekous.profile, world-readable, join rule public. The directory listing is how the global feed finds every profile on the server. Discover lists only Spaces (browsePublicSpaces), so it never shows, and itsfeedchannel type keeps it out of Direct Messages. - Its ID is also published on the author's extended profile (
xyz.nekous.profile_room) and kept in their account data. - Ownership comes from the feed marker, cross-checked against the room's creator, so a hand-edited marker can't claim someone else's name.
An edit is a standard m.replace of the same type: an xyz.nekous.post whose content carries
m.relates_to: { rel_type: "m.replace", event_id } and the whole new content (text, formatting,
media, repost) under m.new_content. Because it's the post type, it's gated like posting: only the
owner (power level 100) can send one. Readers also accept an edit only from the post's own sender.
Edits are applied in the app (applyPostEdits) rather than left to matrix-js-sdk, because the
global feed reads unjoined feeds over plain /messages, where nothing aggregates them. An edit
can arrive on a different page from its post, so the global feed keeps every edit it has seen and
applies the newest to each post. Edits the server bundles into
unsigned["m.relations"]["m.replace"] count too. An edit event never shows as a post of its
own (isPostEvent). An edited post reads "(edited)". Editing changes only the text: media and an
embedded repost carry over unchanged.
A repost may copy any version of a post, so the repost check also accepts a match with one of its author's edits.
A repost is an ordinary post whose content carries xyz.nekous.repost_of: the original's room,
event, author, origin (Global or a Space), time, text and media, embedded whole. Anyone who
can read the repost can read what it reposts, even if they can't read the original's room.
The copy is checked against the original (matrix/repostCheck.ts), since it's whatever the
reposter's client wrote: a hand-made event could "repost" words someone never posted, under their
name. Readers can always read the original (that's the rule below), so each card fetches it once
(GET /rooms/{roomId}/event/{eventId}) and compares the sender, text and media. If it matches, the
copy shows. If the original was deleted (redacted or not found), the card says the post was
removed, so deleting a post, or making it private, takes it out of every repost too. If it doesn't
match, the copy isn't shown. If the check fails for any other reason (offline, forbidden while a
private Space's feed room is still being joined), the copy shows marked "Unchecked", and it's
checked again next time.
That's why reposting only moves content between public places (canRepost): from Global or
a public Space, to Global or a public Space. Copying a post out of a private Space would hand it
to people its author never posted it for. The Repost action only appears on posts from public
places, and the dialog only offers public destinations. Reposting a bare repost (no comment)
reposts the original, so reposts are never nested.
The one exception: a post from a private Space can be reposted within that same Space. Its
audience is exactly the one the original already had, so nothing becomes more visible, and a
private Space still gets a Repost button. canRepost owns the rule and repostTargetsFor applies
it to the destinations offered, so a post nobody could repost anywhere shows no button at all.
Both live in the post's own feed room, related to the post (matrix/postInteractions.ts):
- A like is a plain
m.reactionwith the ❤️ key, so any Matrix client sees a heart reaction. Un-liking redacts it. One like per person is counted, however many reactions they sent. - A comment is
xyz.nekous.commentwith anm.referencerelation to the post. Its content has the same shape as a post's (text, formatting, up to four images/videos underxyz.nekous.attachments), parsed by the samereadPostContent. It's a custom type for the same reasons a post is: it notifies nobody, and Element shows no chat log. Comment media follows the post's privacy: plain uploads under a public post, encrypted in the browser everywhere else.
Reading is one /relations request per post, and a server answers it for a non-member when
the feed is world-readable (checked against Continuwuity), so the global feed shows counts and
threads without joining anything. Requests are capped at six in flight across a page.
Writing joins the feed room first. Feed rooms keep events_default at its default, so once
joined anyone can like or comment; only posting is gated. Anyone can join a profile feed; a
Space's feeds only admit that Space's members, so a post from a Space you're not in shows its
likes and comments with a "Join the Space to like or comment" note instead.
Deleting: your own comments, or anyone's under your own post (you're power level 100 in your feed room). A Space's moderators can remove any post or comment in that Space's feeds.
A redacted like or comment still comes back from /relations, with empty content and
unsigned.redacted_because. Counting only relations that still carry m.relates_to is what
makes un-liking actually lower the count.
Replies. The thread is flat, but any comment can answer another: a reply carries
xyz.nekous.reply_to (the comment and its author) and names that author in m.mentions, and
shows "Replying to …" above its text. Replying to yourself mentions nobody.
Long threads, and a post's own page. A timeline never renders a whole thread: opening a post's
comments there shows its newest 3, and "View all N comments" (or the post's Open action) goes to
the post's own page (PostPage, over whatever it was opened from; Back returns there, and any
other navigation closes it). The page shows the whole thread: the newest 50, then "Show earlier
comments" reveals what's loaded and "Load earlier comments" fetches older pages. Repost and delete
work there too. A card reads its likes and the newest 50 comments; counts read "50+" until the
rest is loaded, never a wrong number.
Older pages don't use /relations pagination, because Continuwuity's is broken backwards:
its next_batch steps back one event instead of one page (50 at a time over a 130-comment
thread returns 130–81, 129–80, 128–79… — following it read 4,099 "comments"), it caps a page at
100, and paging forwards returns nothing. Its first page is right, so that's all that's read from
/relations. Older relations come from the room timeline instead: /context gives a position
just before the oldest one loaded, then /messages pages backwards filtered to that event type,
keeping this post's relations and stopping at the post itself. Checked live: 130 comments on one
post, interleaved with comments on another post in the same feed, come back as 50 + 75 + 5,
exactly once each, nothing from the other post. Likes past the first 100 are read the same way,
up to 1,000 ("1000+" after that).
Likes and comments are silent by default: no default push rule matches a comment's custom event
type, and the server-default .m.rule.reaction rule mutes reactions. So each author gets their
own push rules, one pair per feed they own (matrix/postNotifications.ts): comments notify with
sound, likes notify quietly. User rules outrank server defaults, and each is scoped by
room_id, so they cover only your own feeds, not every feed you've joined to comment on.
Because they're real push rules, the homeserver applies them itself: in-app notifications and
background push both follow. NotificationRules (mounted in AppShell) keeps them in step with
the feeds you own and your settings (Account Settings → Posts), including from another device,
since both live in account data. It reads rules from the client's synced ruleset, because
Continuwuity doesn't implement listing one rule kind. The push gateway words them as "Commented on
your post: …" / "Liked your post", and clicking one opens the posts view, not the feed room as a
channel.
Checked against Continuwuity with a stand-in push gateway: with no rules, nothing is delivered; with them, both arrive, carrying the event type and comment text; the same actions in another room, nothing. With the real sync code, 130 comments and 2 likes on a post produced 132 deliveries.
Replies notify the person replied to, and only them. That needs no rule of ours: the spec's
built-in .m.rule.is_user_mention matches m.mentions on any event type, custom ones included.
Checked on Continuwuity with the real sendComment: of a reply to Bob and a plain comment beside
it, only the reply reached Bob's push gateway. Everyone else in the thread isn't notified, and the
post's author still hears about every comment through their own feed rule.
The wording depends on who's receiving: "Replied to your comment" for the person replied to,
"Commented on your post" for the author. In the app that's known. The push gateway can't know
whose device it's delivering to, so the web app puts the user ID in the pusher's data, which the
homeserver echoes back with every notification. Pushers registered before that fall back to the
highlight tweak, which only the mention rule sets; re-enabling background push upgrades them.
Joining needs a server to join through. From room version 12 (Continuwuity's default) a room
ID has no :server part, so via comes from the feed owner's user ID (feedJoinVia); deriving
it from the room ID alone sends an empty via, which the server rejects.
Posts and comments use the chat composer's @name autocomplete (useMentionAutocomplete). Only a
name picked from the dropdown becomes a real mention, sent as m.mentions.user_ids. The spec's
.m.rule.is_user_mention matches that on any event type, so the person is notified, in the app
and by background push ("Mentioned you in a post / in a comment").
Only people joined to the feed room can be reached. The homeserver delivers a room's events, and so its notifications, only to the room's members. That decides how each kind reaches people:
- A Space post offers the Space's members. Every client joins its Spaces' feed rooms in the
background (
SpaceAutoJoiner: shortly after start, on joining a Space, and whenever a member publishes a feed), so they're there to receive it, not only once they've opened the Posts page. - A comment offers the feed room's joined members: the people who can be reached there.
- A Global post offers everyone the client knows of. They usually aren't in your profile
room, so after the post goes out, its author invites each mentioned person who isn't
(
matrix/mentionInvites.ts), with a reason naming the post:Mentioned you in a post (xyz.nekous.mention $eventId). An invite notifies by default (.m.rule.invite_for_me), and the push gateway words this one "Mentioned you in a post". The invitee's app accepts it by itself (MentionInviteAcceptor), files it in the Mention Inbox, and keeps it out of the Invites list. It only treats an invite that way when it's to a profile room (the room type, frominvite_state), comes from that room's creator, and carries the reason. Anything else stays an ordinary invite. Checked on Continuwuity:invite_statecarries both the room type and the reason, and the invitee joins and reads the post.
The Mention Inbox collects mentions in posts and comments as well as chat, as they arrive.
Opening one opens that post's page (useOpenPost, which fetches the post with its latest edit)
rather than a channel. So does clicking a desktop notification about a post, a comment or a like.
A picture in a post or a comment can say who's in it (matrix/imageTags.ts). In the composer, a staged
picture's Tag button opens it: tap where someone is and pick them from the same people an
@mention offers (ImageTagEditor); tagging the same person again moves them, and a tag can be taken
off. Up to 20 people a picture.
Each image in xyz.nekous.attachments may carry tags:
{ "kind": "image", "url": "mxc://…", "info": { … }, "tags": [ { "user_id": "@luna:purr.example", "x": 3333, "y": 5000 } ] }x and y are how far across and down the picture the spot is, in ten-thousandths (0 to 10000).
Whole numbers, since a Matrix event can't hold fractions (canonical JSON): a homeserver refuses an
event with 0.5 in it. Readers keep only real user IDs, each once, clamped to the picture, and only
on pictures.
Tagging mentions. buildPostContent adds everyone tagged to m.mentions, so a tag notifies
exactly as a mention does ("Mentions", above), including a Global post inviting them to the
author's profile room. It's worded "Tagged you in a photo": the invite's reason, desktop
notifications (DesktopNotifications, MentionInviteAcceptor) and background push
(services/push-gateway/src/postActivity.ts) look for the reader among the tags.
On the post (PostMedia, TaggedImageOverlay): a tagged picture has a small people button
with a count; pressing it shows each name at its spot, on the picture itself however it's cropped
or letterboxed, and a name opens that person's profile. A repost carries the picture and its tags,
but doesn't notify anyone again. The public web never shows tags: the token server copies only a
picture's file, type and size.
Chat messages' pictures can't be tagged yet; only posts and comments.
useGlobalFeed (logic in matrix/globalFeed.ts) reads posts from every place it can, without
joining anything, and the view picks a timeline:
- Everyone: public places only. That means every profile feed, plus every public Space (listed in the directory: Space Settings → Visibility → "Public space"), including Spaces you've never joined.
- Following: people and whole Spaces you follow. Follows live in your account data
(
xyz.nekous.follows), so nobody else can see them and nobody is notified. Following can include a Space you're a member of that isn't public; you can already read it, and its posts never reach Everyone. - Profiles (
ProfileView): one person's posts from everywhere you can read. Opened from any author's name, or "View posts" on a member's profile card.
What "public" means. For a Space, being listed in the directory. The join rule isn't used, because an invite link also makes a Space "anyone can join", and a Space kept unlisted on purpose must never show up on a global surface.
That distinction used to be invisible: "Public join link" said "see General settings" for
listing, but no setting existed after creation. So a Space made joinable by link looked public
and wasn't, and had no Repost button. Space Settings → Visibility → "Public space — listed in
Discover" now shows the server's actual answer and changes it (matrix/spaceDirectory.ts).
Listing sets what creating a public Space sets: listed, world_readable, and open to join.
Unlisting takes it out of the directory and back to members-only history, and leaves the join
link alone.
Your own Spaces are checked one by one (GET /directory/list/room/{roomId}, a definite answer),
not by scanning the directory, which is paged and capped and can miss a listed Space on a busy
server. The directory scan still finds public Spaces you aren't in.
Reading without joining. Public posts come from /messages on world-readable feed rooms
(a private Space's feeds are members-only, so for Spaces you're in they're joined first). Finding
the feeds in a Space you aren't in needs its /state, which the server only allows a non-member
to read if the Space is world-readable. Public Spaces created from now on are
(roomCreation.ts). That exposes the Space's name, topic, member list and feed pointers, never
chat. An older public Space that isn't world-readable is skipped and counted.
Caps. 40 public Spaces, 100 profiles, 200 feeds, 6 requests in flight. The caps follow
directory order, not activity, so on a bigger server Everyone is a sample, and it says so
("global-feed-truncated"). People and Spaces you follow are never cut: they're read directly
(a person's profile feed from xyz.nekous.profile_room on their extended profile, a Space by
checking it's listed and reading its /state) and put first, ahead of the 200-feed cut. A profile
page does the same for the person it shows. Following someone mid-session adds just their feeds. Pagination goes wide,
like the hub timeline: every feed with older history is paged together. Joined feed rooms update
live; the rest are a snapshot with a Refresh button.
A Space's Posts row gets an unread dot when someone else has posted since you last had the
page open (matrix/postsSeen.ts). Posts don't count as unread in Matrix's sense (they notify
nobody), so this is the app's own: the newest post by another member across the Space's feed
rooms, compared with when you last looked. That time is kept per Space in account data
(xyz.nekous.posts_seen), so opening Posts on one device clears the dot on the others. A Space
you've never opened Posts in counts from the start of the session, so every Space doesn't light up
at once for posts that were always there. It relies on the background feed joining (see
"Mentions"): the posts are already synced, so it costs no requests.
features/feed/FeedView.tsx— the whole surface: composer with an "Only me" toggle, an Everyone/Yours tab pair, and the post cards. Reached from the pinned Posts row at the top of a Space's channel list, which setsselectedSpaceViewAtomrather thanselectedRoomIdAtom— the feed is a merge across rooms, not one of them, so it can't be expressed as a selected room. Selecting any channel clears it.- Private posts are re-read on this view's own actions rather than through a hook: account data has no live-update hook in this codebase, and a private post only ever changes in response to something done right here.
- Nested threads. Replies are shown flat, labelled with who they answer, rather than indented under the comment.
- Moderation when the owner is away. A ban reaches someone's feed room only once that feed's owner next opens the app. Until then their comments are hidden, but the room still delivers them to the banned person.
- A "chosen people" privacy tier. Public and only-me are the two tiers. A third would be a second, invite-only feed room per member, where the invite list is the audience.