12 KiB
Community messages panel
A community opens as a panel in the center dock when its sidebar row is clicked, shaped like every Discord-style client (Vector, Armada):
+----------------+----------------------------------+
| Channels | |
| # general | messages |
| # random | |
+----------------+ |
| Members | |
| @alice +----------------------------------+
| @bob | [ composer ] |
+----------------+----------------------------------+
One panel holds both columns. The left column scrolls its two sections; the right
column is the timeline plus the composer. The panel is per community, so its
panel_id is community-<community_id hex> and re-clicking a community focuses
the existing panel (ui::dock::add_panel already does this by panel_id).
What already exists
sync::planesalready derives aPlanefor the Control plane, the Guestbook, and every public channel;CommunityRegistry::sync_subscriptionssubscribes to all of them as one filter, so live channel wraps already reach the client.sync::foldalready opens channel wraps and callsstore::cache_rumor, so the local DB already holds the timeline; it folds the Control Plane and the member list and emitsCommunityEvent::Updatedon every inbound wrap.cord03::foldturns cached rumors intoChatMessages with edits, deletes and reactions resolved;store::query_rumors/store::backfillare the read paths.Communityexposeschannels(),members(),control(),name(),icon().community::initis already called bydesktop/src/main.rs.
So the panel needs no new protocol work: one reader, one writer, and an event that asks the workspace to open the panel.
1. concord: a cached rumor is a ChatRumor
store::query_rumors hands back UnsignedEvents, and cord03::typed (already
used by open) is private. Add one public wrapper in crates/concord/src/cords/cord03.rs:
/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch.
pub fn parse_rumor(rumor: &UnsignedEvent) -> Result<ChatRumor, ChatError> {
let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)?
.ok_or(ChatError::MissingTag(TAG_CHANNEL))?
.parse()
.map_err(|_| ChatError::BadTag(TAG_CHANNEL))?;
let epoch = unique_tag(rumor, TAG_EPOCH)?
.ok_or(ChatError::MissingTag(TAG_EPOCH))
.and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?;
typed(rumor, &channel, Epoch(epoch))
}
The tags are read with cord01::unique_tag, the same reader check_channel_binding
uses (both become pub(crate)), so a cached copy is parsed by exactly the rule that
accepted it at ingest. canonical_decimal and typed are already in this file.
2. community: channel history and sending
All in crates/community/src/community.rs on Community, mirroring Room.
const MESSAGE_LIMIT: usize = 200;
/// The secret a channel's plane derives from, and the epoch it is held at.
/// A private channel uses the key it was granted; a public one the community root.
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])>;
/// Page a channel's history into the local cache, once, when the channel is opened.
pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task<Result<()>>;
/// The channel's timeline, folded from the local cache.
pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task<Result<Vec<ChatMessage>>>;
/// Seal a message to the channel plane, cache it, then publish it to the relays.
pub fn send(
&self,
channel: &ChannelId,
content: &str,
reply_to: Option<ReplyRef>,
cx: &App,
) -> Option<Task<Result<EventId>>>;
backfill:store::backfill(&client, channel, &[(epoch, secret)], None, MESSAGE_LIMIT), skipped whenstore::query_rumorsalready finds wraps for the channel, so it runs once per channel.store::backfillwalks up toMAX_PAGESpages itself. It fetches through the client, so the community's relays must be in the pool —sync_subscriptionsalready adds them on load.messages:store::query_rumors(&client, channel, None, MESSAGE_LIMIT), thencord03::parse_rumorover each, thencord03::fold(&rumors, Timestamp::now(), can_delete). The closure is the community's own policy:citation_ok(&owner, &id, actor, citation, &floors) && roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES), built fromself.state.owner,self.state.id,self.state.floors()andself.control.rolescloned into the background task.cord03::foldreturns newest-first, so reverse it for the bottom-aligned list.send:cord03::build_message(author, channel, epoch, content, reply_to.as_ref(), at_ms, timer)wheretimeriscontrol.community.message_expirationandat_msis now in ms;cord03::seal_rumor(&rumor, &plane, &signer, false); then — the order matters —cord03::open(&wrap, &plane, channel, epoch)andstore::cache_rumorbeforeclient.send_event(&wrap).to(&state.relays), so the author's own row exists whether or not a relay answers. Add the community's relays withadd_relay(..) .and_connect()first, the waysync::publish_wrapsdoes — lifting that loop into apub(crate) sync::publish_wrap(client, &wrap, &relays)keeps one copy. Publish failures onlylog::warn!.seal_rumorneeds the channel'sGroupKeyfromderive::channel_group_key(secret, channel, epoch), and the epoch fromchannel_secret. ReturnsNonewithout a signer or a held secret, and the rumor id so the panel can reload.
CommunityEvent gains one variant, and the registry a way to request an open,
mirroring ChatRegistry::emit_room:
pub enum CommunityEvent {
Updated(CommunityId),
Open(CommunityId),
Error(String),
}
impl CommunityRegistry {
/// Ask the workspace to open a community's panel.
pub fn emit_community(&mut self, community: &Entity<Community>, window: &mut Window, cx: &mut Context<Self>);
}
emit_community reads the id and emits CommunityEvent::Open through
cx.defer_in(window, ...) so the click never re-enters the registry.
Private channels stay out of this pass: sync::planes does not subscribe them and
CommunityState has no room for a rotated key yet, so channel_secret returning
the granted key is the only support they get.
3. community_ui: the new crate
crates/community_ui, shaped like chat_ui (which is the reference for every
detail: Panel impl, notification routing, input handling, message list).
crates/community_ui/Cargo.toml deps: community, state, ui, theme, common, person, settings, gpui, nostr-sdk, smallvec, anyhow, log
crates/community_ui/src/lib.rs init + CommunityPanel
crates/community_ui/src/message.rs one message row's rendering
pub fn init(community: Entity<Community>, window: &mut Window, cx: &mut App) -> Entity<CommunityPanel>;
pub struct CommunityPanel {
id: SharedString, // "community-<hex>"
focus_handle: FocusHandle,
community: WeakEntity<Community>,
channel: Option<ChannelId>, // the selected channel
messages: Vec<ChatMessage>, // ascending, bottom-aligned list
message_index: HashMap<EventId, usize>,
list_state: ListState,
input: Entity<TextareaState>,
tasks: Vec<Task<Result<()>>>,
subscriptions: SmallVec<[Subscription; 2]>,
}
newtakes the strongEntity<Community>, subscribes withcx.subscribe_in(&community, window, ...)while it has it, and keeps only the weak handle afterwards (ChatPanel::subscribe_room_eventsis the same split). It pickschannels().first()(the genesis#general) and, in the subscription,CommunityEvent::Updated(id)reloads the open channel whileCommunityEvent::Error(error)becomes a window notification. Acx.defer_indoes the firstbackfill+messagesload, exactly asChatPanel::newdefersconnect.- The channel and member lists are read live in
renderthrough the weak entity (as the sidebar readsCommunity::channels()), so a new channel or member needs no invalidation; a dropped entity renders an empty state instead. select_channel(channel, window, cx)swaps the selection, resets the list and loads:backfillonce per channel, thenmessages.reload(cx)awaitscommunity.messages(&channel, cx), replacesmessages, rebuildsmessage_indexandlist_state.reset(len)(thenscroll_to_end). Edits, deletes and reactions are folded server-side of the UI, so a full replace is the honest update and stays small atMESSAGE_LIMIT.send(window, cx)readsself.input, callscommunity.send(...), clears the input, and reloads when the task resolves. Empty input is refused with a notification, likeChatPanel.render:v_flexholdingh_flex- left:
w(px(220.)),border_r_1,.overflow_y_scrollbar()column with aChannelssection (row = iconIconName::Message, orLockwhen private, plusChannelKeyRef.name; the selected row takescx.theme().ghost_element_selected) and aMemberssection (row =AvatarfromPersonRegistry::global(cx).read(cx).get(&pk, cx)plus the profile name, honouringAppSettings::get_hide_avatarlikeTreeRow). - right:
v_flex().flex_1().min_w_0()withgpui::list(self.list_state, ...)overmessage::render(...)andScrollbar::vertical(&self.list_state), then the composer row:Textarea(InputEvent::PressEntersends) and aButton::new("send").icon(IconName::PaperPlaneFill).
- left:
- A message row: author name (person profile, "Unknown" fallback),
at_ago()fromcommon::TimestampExt, the content as plain text (no markdown, media or file rendering in this pass), a muted(edited)marker whenedited_atis set, an emoji summary line fromreactions, and"Message deleted"incx.theme().text_placeholderwhendeleted. Panel:panel_id= the id above,title= the community icon (Avatar) pluscommunity.name(),closable= true, no toolbar buttons.
4. workspace: open the panel from the sidebar
crates/workspace/Cargo.toml: addcommunity_ui = { path = "../community_ui" }.crates/workspace/src/lib.rs: subscribe toCommunityRegistrybeside the chat subscription and, onCommunityEvent::Open(id), look the community up withregistry.read(cx).community(&id)andadd_panel_to_dock(community_ui::init(community, window, cx), DockPlacement::Center, window, cx).CommunityEvent::Errorkeeps its single handler in the sidebar.crates/workspace/src/sidebar/mod.rs:open_communitykeeps recording the recent community and now ends withCommunityRegistry::global(cx).update(cx, |registry, cx| registry.emit_community(&community, window, cx)), so the row's click handler needs thewindow.
5. Order of work
cord03::parse_rumor.community:channel_secret,backfill,messages,send,CommunityEvent::Open,emit_community.community_ui:message.rs, then the panel with the channel list, the timeline and the composer, then the member list.workspace: the dependency, the registry subscription, the sidebar click.cargo check -p workspace(the panel only compiles through it), then a manual run: create a community, click its sidebar row, send a message and see it through a second account.
No tests: the crate follows the "no unwrap, errors to the UI" rule and validation
is the manual run above.
Out of scope
Files, reactions as a composer action, edits, threads, pins, typing indicators,
unread badges, notifications, message expiration purging (store::purge_expired),
private-channel subscriptions (a rekey cannot be persisted yet), moderation actions,
and community management (metadata, roles, invites). Also unchanged:
crates/chat/src/lib.rs::handle_notifications already routes kind 1059 wraps by
subscription id, so concord traffic does not land in the DM trash.