241 lines
12 KiB
Markdown
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.
|