Files
Marcos Paulo bc0b258e04 feat: engine and core crates: comment sidecars hidden and removed with the note, e2e comments test, extension tables, annotate text and arrow items
Sidecars (*.comments.json) stay out of the index and listings and follow rename and delete of their note; end-to-end comments test over a stub server (Ana comments and mentions Bob, Bob resolves, second sync changes nothing); viewer extension tables gain ico, ogv, 3gp, opus, aac; annotate crate: Edit::Replace for undoable text moves and edits, line items carry an arrow flag; syntax test fixed (TeX is a bundled grammar). Built by MiniMax M3.1 Flash and Claude Sonnet; verified with the full cargo xtask ci on the combined tree (4422 tests, clippy -D warnings, layers, tree, i18n).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 02:31:52 -03:00

135 lines
5.6 KiB
Rust

//! The feed of the fake: the events a client posts, the events of a member and
//! the events it has read.
//!
//! The rules are the ones of the server: the actor of an event never sees it, a
//! `mention` reaches only the members it names and every other event reaches
//! the rest of the vault.
use serde_json::{Value, json};
use super::{
ANA, AUDIENCE_MENTION, AUDIENCE_VAULT, BOB, Event, KIND_COMMENT, KIND_MENTION, KIND_THREAD_RESOLVED, MAX_PAGE, Reply, SIDECAR_SUFFIX, State, query_number,
strings, user_of,
};
use crate::comments_e2e_stub::wire::Request;
/// The members of the vault, as the server holds them.
pub(crate) fn members() -> Vec<Value> {
vec![
json!({ "user_id": ANA, "name": "Ana", "color": "#c678dd", "role": "owner", "last_seen_ms": Value::Null }),
json!({ "user_id": BOB, "name": "Bob", "color": "#61afef", "role": "editor", "last_seen_ms": Value::Null }),
]
}
/// Stores one event of the feed.
pub(crate) fn post_event(state: &mut State, request: &Request) -> Reply {
let body = request.json();
let kind = body.get("kind").and_then(Value::as_str).unwrap_or_default().to_owned();
let mentions = strings(&body, "mentions");
let audience = if kind == KIND_MENTION { AUDIENCE_MENTION } else { AUDIENCE_VAULT };
let event = Event {
id: 0,
kind: kind.clone(),
actor: user_of(request),
audience: audience.to_owned(),
note: body.get("note").and_then(Value::as_str).map(str::to_owned),
thread_id: body.get("thread_id").and_then(Value::as_str).map(str::to_owned),
mentions: mentions.clone(),
posted: true,
};
let id = state.push_event(event);
let recipients = if audience == AUDIENCE_MENTION { mentions.len() } else { state.members.len().saturating_sub(1) };
Reply::json(200, &json!({ "id": id, "kind": kind, "head": state.head(), "recipients": recipients }))
}
/// The page of the feed of the member.
pub(crate) fn list_events(state: &State, request: &Request, query: &str) -> Reply {
let user = user_of(request);
let since = query_number(query, "since").unwrap_or(0);
let limit = usize::try_from(query_number(query, "limit").unwrap_or(50).clamp(1, MAX_PAGE)).unwrap_or(usize::try_from(MAX_PAGE).unwrap_or(usize::MAX));
let visible = state.visible(&user);
let unread = state.unread(&user);
let rows: Vec<Value> = visible.iter().filter(|event| event.id > since).take(limit).map(|event| event_json(event, state, &user)).collect();
Reply::json(200, &json!({ "events": rows, "head": state.head(), "more": false, "unread": unread }))
}
/// Marks the events of the member read.
pub(crate) fn mark_read(state: &mut State, request: &Request) -> Reply {
let user = user_of(request);
let ids: Vec<u64> = request.json().get("ids").and_then(Value::as_array).map(|list| list.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
let targets: Vec<u64> = state.visible(&user).iter().filter(|event| ids.is_empty() || ids.contains(&event.id)).map(|event| event.id).collect();
for id in targets {
state.read.insert((user.clone(), id));
}
Reply::json(200, &json!({ "unread": state.unread(&user) }))
}
/// One event as the row of the feed of a member.
fn event_json(event: &Event, state: &State, user: &str) -> Value {
json!({
"id": event.id,
"ts_ms": 10,
"kind": event.kind,
"actor": event.actor,
"actor_name": name_of(&event.actor),
"note": event.note,
"thread_id": event.thread_id,
"line": Value::Null,
"read": state.is_read(user, event.id),
})
}
/// The name of a member of the vault.
fn name_of(user: &str) -> &'static str {
match user {
ANA => "Ana",
BOB => "Bob",
_ => "Stranger",
}
}
/// The events a sidecar brings with it.
///
/// A thread that is new posts a `comment` for the whole vault; a thread that
/// was closed posts a `thread_resolved`. The events no client posts yet are
/// derived here, the way the server derives `note_changed` from an upload.
/// A client that posted the event itself is never doubled.
pub(crate) fn derive_events(state: &mut State, actor: &str, path: &str, bytes: &[u8]) {
if !path.ends_with(SIDECAR_SUFFIX) {
return;
}
let Ok(value) = serde_json::from_slice::<Value>(bytes) else { return };
let note = value.get("note").and_then(Value::as_str).unwrap_or_default().to_owned();
let Some(comments) = value.get("comments").and_then(Value::as_array) else { return };
for comment in comments {
let Some(id) = comment.get("id").and_then(Value::as_str) else { continue };
let resolved = comment.get("resolved").and_then(Value::as_bool).unwrap_or(false);
let was = state.threads.get(id).copied();
state.threads.insert(id.to_owned(), resolved);
let kind = match was {
None => Some(KIND_COMMENT),
Some(false) if resolved => Some(KIND_THREAD_RESOLVED),
Some(_) => None,
};
if let Some(kind) = kind
&& !posted(state, kind, id)
{
state.push_event(Event {
id: 0,
kind: kind.to_owned(),
actor: actor.to_owned(),
audience: AUDIENCE_VAULT.to_owned(),
note: Some(note.clone()),
thread_id: Some(id.to_owned()),
mentions: Vec::new(),
posted: false,
});
}
}
}
/// True when a client already posted that event for that thread.
fn posted(state: &State, kind: &str, thread: &str) -> bool {
state.events.iter().any(|event| event.posted && event.kind == kind && event.thread_id.as_deref() == Some(thread))
}