# 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-` and re-clicking a community focuses the existing panel (`ui::dock::add_panel` already does this by `panel_id`). ## What already exists - `sync::planes` already derives a `Plane` for the Control plane, the Guestbook, and every public channel; `CommunityRegistry::sync_subscriptions` subscribes to all of them as one filter, so live channel wraps already reach the client. - `sync::fold` already opens channel wraps and calls `store::cache_rumor`, so the local DB already holds the timeline; it folds the Control Plane and the member list and emits `CommunityEvent::Updated` on every inbound wrap. - `cord03::fold` turns cached rumors into `ChatMessage`s with edits, deletes and reactions resolved; `store::query_rumors` / `store::backfill` are the read paths. - `Community` exposes `channels()`, `members()`, `control()`, `name()`, `icon()`. - `community::init` is already called by `desktop/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 `UnsignedEvent`s, and `cord03::typed` (already used by `open`) is private. Add one public wrapper in `crates/concord/src/cords/cord03.rs`: ```rust /// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch. pub fn parse_rumor(rumor: &UnsignedEvent) -> Result { 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`. ```rust 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>; /// The channel's timeline, folded from the local cache. pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task>>; /// 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, cx: &App, ) -> Option>>; ``` - `backfill`: `store::backfill(&client, channel, &[(epoch, secret)], None, MESSAGE_LIMIT)`, skipped when `store::query_rumors` already finds wraps for the channel, so it runs once per channel. `store::backfill` walks up to `MAX_PAGES` pages itself. It fetches through the client, so the community's relays must be in the pool — `sync_subscriptions` already adds them on load. - `messages`: `store::query_rumors(&client, channel, None, MESSAGE_LIMIT)`, then `cord03::parse_rumor` over each, then `cord03::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 from `self.state.owner`, `self.state.id`, `self.state.floors()` and `self.control.roles` cloned into the background task. `cord03::fold` returns 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)` where `timer` is `control.community.message_expiration` and `at_ms` is now in ms; `cord03::seal_rumor(&rumor, &plane, &signer, false)`; then — the order matters — `cord03::open(&wrap, &plane, channel, epoch)` and `store::cache_rumor` *before* publishing, so the author's own row exists whether or not a relay answers. The publish is `pub(crate) sync::connect_relays(client, &relays)` (the `add_relay(..).and_connect()` loop the genesis path already ran) followed by `pub(crate) sync::publish_wrap(client, &wrap, &relays)`, so one copy serves both paths; failures only `log::warn!`. `seal_rumor` needs the channel's `GroupKey` from `derive::channel_group_key(secret, channel, epoch)`, and the epoch from `channel_secret`. Returns `None` without 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`: ```rust 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, window: &mut Window, cx: &mut Context); } ``` `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 ``` ```rust pub fn init(community: Entity, window: &mut Window, cx: &mut App) -> Entity; pub struct CommunityPanel { id: SharedString, // "community-" focus_handle: FocusHandle, community: WeakEntity, channel: Option, // the selected channel messages: Vec, // ascending, bottom-aligned list message_index: HashMap, list_state: ListState, input: Entity, tasks: Vec>>, subscriptions: SmallVec<[Subscription; 2]>, } ``` - `new` takes the strong `Entity`, subscribes with `cx.subscribe_in(&community, window, ...)` while it has it, and keeps only the weak handle afterwards (`ChatPanel::subscribe_room_events` is the same split). It picks `channels().first()` (the genesis `#general`) and, in the subscription, `CommunityEvent::Updated(id)` reloads the open channel while `CommunityEvent::Error(error)` becomes a window notification. A `cx.defer_in` does the first `backfill` + `messages` load, exactly as `ChatPanel::new` defers `connect`. - The channel and member lists are read live in `render` through the weak entity (as the sidebar reads `Community::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: `backfill` once per channel, then `messages`. - `reload(cx)` awaits `community.messages(&channel, cx)`, replaces `messages`, rebuilds `message_index` and `list_state.reset(len)` (then `scroll_to_end`). Edits, deletes and reactions are folded server-side of the UI, so a full replace is the honest update and stays small at `MESSAGE_LIMIT`. - `send(window, cx)` reads `self.input`, calls `community.send(...)`, clears the input, and reloads when the task resolves. Empty input is refused with a notification, like `ChatPanel`. - `render`: `v_flex` holding `h_flex` - left: `w(px(220.))`, `border_r_1`, `.overflow_y_scrollbar()` column with a `Channels` section (row = icon `IconName::Message`, or `Lock` when private, plus `ChannelKeyRef.name`; the selected row takes `cx.theme().ghost_element_selected`) and a `Members` section (row = `Avatar` from `PersonRegistry::global(cx).read(cx).get(&pk, cx)` plus the profile name, honouring `AppSettings::get_hide_avatar` like `TreeRow`). - right: `v_flex().flex_1().min_w_0()` with `gpui::list(self.list_state, ...)` over `message::render(...)` and `Scrollbar::vertical(&self.list_state)`, then the composer row: `Textarea` (`InputEvent::PressEnter` sends) and a `Button::new("send").icon(IconName::PaperPlaneFill)`. - A message row: author name (person profile, "Unknown" fallback), `at_ago()` from `common::TimestampExt`, the content as plain text (no markdown, media or file rendering in this pass), a muted `(edited)` marker when `edited_at` is set, an emoji summary line from `reactions`, and `"Message deleted"` in `cx.theme().text_placeholder` when `deleted`. - `Panel`: `panel_id` = the id above, `title` = the community icon (`Avatar`) plus `community.name()`, `closable` = true, no toolbar buttons. ## 4. `workspace`: open the panel from the sidebar - `crates/workspace/Cargo.toml`: add `community_ui = { path = "../community_ui" }`. - `crates/workspace/src/lib.rs`: subscribe to `CommunityRegistry` beside the chat subscription and, on `CommunityEvent::Open(id)`, look the community up with `registry.read(cx).community(&id)` and `add_panel_to_dock(community_ui::init(community, window, cx), DockPlacement::Center, window, cx)`. `CommunityEvent::Error` keeps its single handler in the sidebar. - `crates/workspace/src/sidebar/mod.rs`: `open_community` keeps recording the recent community and now ends with `CommunityRegistry::global(cx).update(cx, |registry, cx| registry.emit_community(&community, window, cx))`, so the row's click handler needs the `window`. ## 5. Order of work 1. `cord03::parse_rumor`. 2. `community`: `channel_secret`, `backfill`, `messages`, `send`, `CommunityEvent::Open`, `emit_community`. 3. `community_ui`: `message.rs`, then the panel with the channel list, the timeline and the composer, then the member list. 4. `workspace`: the dependency, the registry subscription, the sidebar click. 5. `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.