Files
coop/docs/community-messages-panel-plan.md
T
2026-09-20 09:23:27 +07:00

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::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 ChatMessages 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 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 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 client.send_event(&wrap).to(&state.relays), so the author's own row exists whether or not a relay answers. Add the community's relays with add_relay(..) .and_connect() first, the way sync::publish_wraps does — lifting that loop into a pub(crate) sync::publish_wrap(client, &wrap, &relays) keeps one copy. Publish 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:

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]>,
}
  • new takes the strong Entity<Community>, 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.