update concord bcakend
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user