bc0b258e04
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>
135 lines
5.6 KiB
Rust
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))
|
|
}
|