add timeline paging for older messages
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, anyhow, bail};
|
||||||
use chat::Message;
|
use chat::Message;
|
||||||
@@ -14,6 +15,8 @@ use crate::protocol::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const REPLAY_LIMIT: usize = 100;
|
const REPLAY_LIMIT: usize = 100;
|
||||||
|
const PAGE_LIMIT: usize = 50;
|
||||||
|
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Absorbed {
|
enum Absorbed {
|
||||||
@@ -44,7 +47,9 @@ pub struct Group {
|
|||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
index: HashSet<EventId>,
|
index: HashSet<EventId>,
|
||||||
window: TimelineWindow,
|
window: TimelineWindow,
|
||||||
|
oldest: Option<Timestamp>,
|
||||||
loading: bool,
|
loading: bool,
|
||||||
|
loading_more: bool,
|
||||||
refused: Option<String>,
|
refused: Option<String>,
|
||||||
tasks: SmallVec<[Task<()>; 2]>,
|
tasks: SmallVec<[Task<()>; 2]>,
|
||||||
}
|
}
|
||||||
@@ -66,7 +71,9 @@ impl Group {
|
|||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
index: HashSet::new(),
|
index: HashSet::new(),
|
||||||
window: TimelineWindow::new(),
|
window: TimelineWindow::new(),
|
||||||
|
oldest: None,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
loading_more: false,
|
||||||
refused: None,
|
refused: None,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
}
|
}
|
||||||
@@ -108,6 +115,14 @@ impl Group {
|
|||||||
self.loading
|
self.loading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn loading_more(&self) -> bool {
|
||||||
|
self.loading_more
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn oldest(&self) -> Option<Timestamp> {
|
||||||
|
self.oldest
|
||||||
|
}
|
||||||
|
|
||||||
pub fn refused(&self) -> Option<&str> {
|
pub fn refused(&self) -> Option<&str> {
|
||||||
self.refused.as_deref()
|
self.refused.as_deref()
|
||||||
}
|
}
|
||||||
@@ -269,6 +284,45 @@ impl Group {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_page(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(until) = self.oldest else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.loading_more = true;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let id = crate::page_subscription(&self.key);
|
||||||
|
let relay = self.key.relay().clone();
|
||||||
|
let filter = self.key.id().timeline_page(PAGE_LIMIT, until);
|
||||||
|
|
||||||
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
|
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||||
|
|
||||||
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
|
.timeout(Some(PAGE_TIMEOUT));
|
||||||
|
|
||||||
|
if let Err(error) = client
|
||||||
|
.subscribe(ReqTarget::single(relay, [filter]))
|
||||||
|
.with_id(id)
|
||||||
|
.close_on(opts)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("nip29: loading older messages failed: {error}");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn page_finished(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.loading_more = false;
|
||||||
|
cx.emit(GroupEvent::Updated);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> {
|
pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> {
|
||||||
let cache_tag = self.key.cache_tag();
|
let cache_tag = self.key.cache_tag();
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -366,7 +420,7 @@ impl Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if MEMBERSHIP_KINDS.contains(&event.kind) {
|
if MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||||
self.window.push(event.clone());
|
self.track(event);
|
||||||
|
|
||||||
if !event.tags.public_keys().any(|key| key == me) {
|
if !event.tags.public_keys().any(|key| key == me) {
|
||||||
return Absorbed::Ignored;
|
return Absorbed::Ignored;
|
||||||
@@ -387,7 +441,7 @@ impl Group {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
self.window.push(event.clone());
|
self.track(event);
|
||||||
|
|
||||||
if RENDER_KINDS.contains(&event.kind) {
|
if RENDER_KINDS.contains(&event.kind) {
|
||||||
Absorbed::Rendered {
|
Absorbed::Rendered {
|
||||||
@@ -398,6 +452,14 @@ impl Group {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn track(&mut self, event: &Event) {
|
||||||
|
self.window.push(event.clone());
|
||||||
|
|
||||||
|
if self.oldest.is_none_or(|oldest| event.created_at < oldest) {
|
||||||
|
self.oldest = Some(event.created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn absorb_state(&mut self, event: &Event) -> bool {
|
fn absorb_state(&mut self, event: &Event) -> bool {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||||
|
|||||||
+61
-1
@@ -21,6 +21,7 @@ pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
|||||||
const LIST_SUBSCRIPTION: &str = "nip29-list";
|
const LIST_SUBSCRIPTION: &str = "nip29-list";
|
||||||
const GROUP_PREFIX: &str = "grp-";
|
const GROUP_PREFIX: &str = "grp-";
|
||||||
const BROWSE_PREFIX: &str = "brw-";
|
const BROWSE_PREFIX: &str = "brw-";
|
||||||
|
const PAGE_PREFIX: &str = "pg-";
|
||||||
const BROWSE_LIMIT: usize = 500;
|
const BROWSE_LIMIT: usize = 500;
|
||||||
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
@@ -67,9 +68,20 @@ fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
|||||||
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||||
|
SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_page(id: &SubscriptionId) -> bool {
|
||||||
|
id.as_str().starts_with(PAGE_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_own_subscription(id: &SubscriptionId) -> bool {
|
fn is_own_subscription(id: &SubscriptionId) -> bool {
|
||||||
let id = id.as_str();
|
let id = id.as_str();
|
||||||
id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX)
|
id == LIST_SUBSCRIPTION
|
||||||
|
|| id.starts_with(GROUP_PREFIX)
|
||||||
|
|| id.starts_with(BROWSE_PREFIX)
|
||||||
|
|| id.starts_with(PAGE_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<GroupsEvent> for GroupsRegistry {}
|
impl EventEmitter<GroupsEvent> for GroupsRegistry {}
|
||||||
@@ -259,6 +271,22 @@ impl GroupsRegistry {
|
|||||||
self.browse.get(&browse_subscription(relay))
|
self.browse.get(&browse_subscription(relay))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||||
|
let Some(group) = self.group(key, cx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if group.read(cx).loading_more() || group.read(cx).oldest().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.routes.insert(page_subscription(key), key.clone());
|
||||||
|
|
||||||
|
group.update(cx, |group, cx| {
|
||||||
|
group.load_page(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||||
let Some(group) = self.group(key, cx) else {
|
let Some(group) = self.group(key, cx) else {
|
||||||
return;
|
return;
|
||||||
@@ -394,9 +422,11 @@ impl GroupsRegistry {
|
|||||||
|
|
||||||
fn forget(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
fn forget(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||||
let id = subscription_id(&key);
|
let id = subscription_id(&key);
|
||||||
|
let page = page_subscription(&key);
|
||||||
let cache_tag = key.cache_tag();
|
let cache_tag = key.cache_tag();
|
||||||
|
|
||||||
self.routes.remove(&id);
|
self.routes.remove(&id);
|
||||||
|
self.routes.remove(&page);
|
||||||
self.observers.remove(&key);
|
self.observers.remove(&key);
|
||||||
self.groups.retain(|group| group.read(cx).key() != &key);
|
self.groups.retain(|group| group.read(cx).key() != &key);
|
||||||
self.list.remove(&key);
|
self.list.remove(&key);
|
||||||
@@ -405,9 +435,11 @@ impl GroupsRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
|
for id in [id, page] {
|
||||||
if let Err(error) = client.unsubscribe(&id).await {
|
if let Err(error) = client.unsubscribe(&id).await {
|
||||||
log::warn!("nip29: unsubscribing {id} failed: {error}");
|
log::warn!("nip29: unsubscribing {id} failed: {error}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
@@ -595,6 +627,18 @@ impl GroupsRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if is_page(&id) {
|
||||||
|
self.routes.remove(&id);
|
||||||
|
|
||||||
|
if let Some(group) = self.group(&key, cx) {
|
||||||
|
group.update(cx, |group, cx| {
|
||||||
|
group.page_finished(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(group) = self.group(&key, cx) else {
|
let Some(group) = self.group(&key, cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -620,6 +664,22 @@ impl GroupsRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if is_page(&id) {
|
||||||
|
self.routes.remove(&id);
|
||||||
|
log::warn!(
|
||||||
|
"nip29: loading older messages in {} was refused: {reason}",
|
||||||
|
key.id()
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(group) = self.group(&key, cx) {
|
||||||
|
group.update(cx, |group, cx| {
|
||||||
|
group.page_finished(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(group) = self.group(&key, cx) else {
|
let Some(group) = self.group(&key, cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ impl GroupId {
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn timeline_page(&self, limit: usize, until: Timestamp) -> Filter {
|
||||||
|
self.timeline_filter(limit).until(until)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn state_filter(&self) -> Filter {
|
pub fn state_filter(&self) -> Filter {
|
||||||
Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS)
|
Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ GroupsRegistry ──ensure relay──► Group entity ◄──ingest── ba
|
|||||||
UI later: GroupPanel (Vec<Message>), JoinDialog (naddr)
|
UI later: GroupPanel (Vec<Message>), JoinDialog (naddr)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A group's subscriptions are the standing `grp-<hash>` REQ (timeline + state +
|
||||||
|
membership, the timeline replayed up to 100 events) and, on demand, a
|
||||||
|
`pg-<hash>` REQ that re-asks the same timeline filter with `until` set to the
|
||||||
|
oldest event hydrated so far. Both are recognised by the same pump and land in
|
||||||
|
the same envelope cache, so paging needs no separate read path.
|
||||||
|
|
||||||
## Phase 0 — `crates/nip29/src/protocol.rs` (pure, no I/O)
|
## Phase 0 — `crates/nip29/src/protocol.rs` (pure, no I/O)
|
||||||
|
|
||||||
The protocol is expressed as types with `impl` blocks: a parsed thing is a struct
|
The protocol is expressed as types with `impl` blocks: a parsed thing is a struct
|
||||||
@@ -437,7 +443,32 @@ group's relay only, and treat the relay's `OK` as the truth.
|
|||||||
`metadata_tags` re-emits them — `9002` requires it, an edit that drops a
|
`metadata_tags` re-emits them — `9002` requires it, an edit that drops a
|
||||||
child is rejected. `parent`/`children` are per-relay, so assembling the tree
|
child is rejected. `parent`/`children` are per-relay, so assembling the tree
|
||||||
from `Group::metadata()` is UI work; there is no backend tree helper.
|
from `Group::metadata()` is UI work; there is no backend tree helper.
|
||||||
4. Range backfill: page the timeline with `until` for "load more".
|
4. Range backfill: page the timeline with `until` for "load more". **Done.**
|
||||||
|
`GroupId::timeline_page(limit, until)` is `timeline_filter` plus `.until(..)`,
|
||||||
|
and `Group::load_page` sends it as a `pg-<hash>` subscription (auto-close on
|
||||||
|
EOSE, 10s cap) alongside the standing `grp-<hash>` one, which keeps running so
|
||||||
|
paging cannot replace the live tail. The read path is the usual one: `pg-`
|
||||||
|
joins `is_own_subscription`, the id is registered in `routes` against the same
|
||||||
|
key, and `handle_event` therefore caches each page event and `reload`s the
|
||||||
|
group, so it arrives through the envelope cache and the timeline like any
|
||||||
|
other event. `GroupsRegistry::load_more(key, cx)` is the entry point: it does
|
||||||
|
nothing while a page is in flight (`Group::loading_more`) or before anything is
|
||||||
|
cached, and registers the route before `Group::load_page` sets the flag.
|
||||||
|
`handle_eose` and a page `CLOSED` remove the route and call
|
||||||
|
`Group::page_finished`, which clears the flag and emits `GroupEvent::Updated`
|
||||||
|
so a panel's spinner can settle. The cursor is `Group::oldest()`, the oldest
|
||||||
|
`h`-tagged event ever hydrated, updated by `track` next to `window.push` —
|
||||||
|
*not* read from `TimelineWindow`, which keeps only the newest 50 events, so a
|
||||||
|
cursor taken from it would never move and would page the same window forever.
|
||||||
|
`until` is inclusive, so a page repeats the event it starts from; `hydrate`
|
||||||
|
dedups by id, so that costs one redundant fetch, never a gap. `forget` also
|
||||||
|
drops and unsubscribes the `pg-` route (`reset` already sweeps `routes`). Two
|
||||||
|
gaps left for the UI phase: a relay that accepts the REQ but never EOSEs leaves
|
||||||
|
`loading_more` true (the auto-close timeout closes the subscription locally and
|
||||||
|
emits no notification — the same stickiness as `Browse::loading`), and a group
|
||||||
|
whose next page shares the `until` second cannot advance the cursor, so it can
|
||||||
|
re-fetch the same page. Neither loops on its own; both need a "load older
|
||||||
|
messages" button bound to `Group::loading_more()` to be observable at all.
|
||||||
5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token
|
5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token
|
||||||
endpoint with a NIP-98 `27235` authorization event) and fork/migration
|
endpoint with a NIP-98 `27235` authorization event) and fork/migration
|
||||||
detection through the admins' `10009`.
|
detection through the admins' `10009`.
|
||||||
@@ -449,6 +480,7 @@ group's relay only, and treat the relay's `OK` as the truth.
|
|||||||
| Paste an naddr and join (or retry a refused join) | `GroupReference::parse` → `GroupsRegistry::join` |
|
| Paste an naddr and join (or retry a refused join) | `GroupReference::parse` → `GroupsRegistry::join` |
|
||||||
| Open a group | `GroupsRegistry::group(&key, cx)` → panel subscribes to `GroupEvent` |
|
| Open a group | `GroupsRegistry::group(&key, cx)` → panel subscribes to `GroupEvent` |
|
||||||
| Timeline | `Group::messages()` (already `chat::Message`s) |
|
| Timeline | `Group::messages()` (already `chat::Message`s) |
|
||||||
|
| Load older messages | `GroupsRegistry::load_more(&key, cx)`; spinner from `Group::loading_more()` |
|
||||||
| Send / react | `Group::send_message` / `send_reaction` |
|
| Send / react | `Group::send_message` / `send_reaction` |
|
||||||
| Members, admins, roles, flags | `Group::members/admins/roles/metadata` |
|
| Members, admins, roles, flags | `Group::members/admins/roles/metadata` |
|
||||||
| Membership badge (member, pending, refused, removed) | `Group::membership()` |
|
| Membership badge (member, pending, refused, removed) | `Group::membership()` |
|
||||||
|
|||||||
Reference in New Issue
Block a user