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

241 lines
12 KiB
Markdown

# 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 `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<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`.
```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<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`:
```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<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
```
```rust
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.