refactor(*): use term msg to replace item

- `Msg` or `msg` is now the canonical term for the substructure in a
  room. It includes a `chat` subtype and (in the future) other
  administration subtypes like member joining or leaving.

- `Message` or `message` can used in human oriented context like docs
  and comments, but only when it is unambiguous.

- `message` is not chosen in code because it's hard to type (at least
  for me!), and have ambiguous meaning of:
  - "Human readable text" in context of `ApiError`'s field.
  - "A unit of data transfer, datagram" in context of WebSocket Message.

- `item` is not chosen because it is overly generic.
This commit is contained in:
oxalica 2024-09-13 03:11:51 -04:00
parent 4acc103afa
commit 73eb441a26
8 changed files with 171 additions and 175 deletions

View file

@ -14,7 +14,7 @@ use serde_with::{serde_as, DisplayFromStr};
pub use bitflags; pub use bitflags;
pub use ed25519_dalek; pub use ed25519_dalek;
/// An opaque server-specific ID for room, chat item, and etc. /// An opaque server-specific ID for rooms, messages, and etc.
/// It's currently serialized as a string for JavaScript's convenience. /// It's currently serialized as a string for JavaScript's convenience.
#[serde_as] #[serde_as]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@ -34,15 +34,15 @@ impl Id {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WithItemId<T> { pub struct WithMsgId<T> {
pub cid: Id, pub cid: Id,
#[serde(flatten)] #[serde(flatten)]
pub item: T, pub msg: T,
} }
impl<T> WithItemId<T> { impl<T> WithMsgId<T> {
pub fn new(cid: Id, item: T) -> Self { pub fn new(cid: Id, msg: T) -> Self {
Self { cid, item } Self { cid, msg }
} }
} }
@ -308,7 +308,7 @@ impl RichText {
} }
} }
pub type ChatItem = WithSig<ChatPayload>; pub type SignedChatMsg = WithSig<ChatPayload>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomMetadata { pub struct RoomMetadata {
@ -320,14 +320,14 @@ pub struct RoomMetadata {
pub attrs: RoomAttrs, pub attrs: RoomAttrs,
// Extra information is only available for some APIs. // Extra information is only available for some APIs.
/// The last item in the room. /// The last message in the room.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub last_item: Option<WithItemId<ChatItem>>, pub last_msg: Option<WithMsgId<SignedChatMsg>>,
/// The current user's last seen item id. /// The current user's last seen message's `cid`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub last_seen_cid: Option<Id>, pub last_seen_cid: Option<Id>,
/// The number of unseen messages, ie. the number of items from `last_seen_cid` to /// The number of unseen messages, ie. the number of messages from `last_seen_cid` to
/// `last_item.cid`. /// `last_msg.cid`.
/// This may or may not be a precise number. /// This may or may not be a precise number.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub unseen_cnt: Option<u64>, pub unseen_cnt: Option<u64>,
@ -547,11 +547,11 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn canonical_chat() { fn canonical_msg() {
let mut fake_rng = rand::rngs::mock::StepRng::new(0x42, 1); let mut fake_rng = rand::rngs::mock::StepRng::new(0x42, 1);
let signing_key = SigningKey::from_bytes(&[0x42; 32]); let signing_key = SigningKey::from_bytes(&[0x42; 32]);
let timestamp = 0xDEAD_BEEF; let timestamp = 0xDEAD_BEEF;
let item = WithSig::sign( let msg = WithSig::sign(
&signing_key, &signing_key,
timestamp, timestamp,
&mut fake_rng, &mut fake_rng,
@ -562,15 +562,15 @@ mod tests {
) )
.unwrap(); .unwrap();
let json = serde_jcs::to_string(&item).unwrap(); let json = serde_jcs::to_string(&msg).unwrap();
let expect = expect![[ let expect = expect![[
r#"{"sig":"18ee190722bebfd438c82f34890540d91578b4ba9f6c0c6011cc4fd751a321e32e9442d00dad1920799c54db011694c72a9ba993b408922e9997119209aa5e09","signee":{"nonce":66,"payload":{"rich_text":["hello"],"room":"42","typ":"chat"},"timestamp":3735928559,"user":"2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12"}}"# r#"{"sig":"18ee190722bebfd438c82f34890540d91578b4ba9f6c0c6011cc4fd751a321e32e9442d00dad1920799c54db011694c72a9ba993b408922e9997119209aa5e09","signee":{"nonce":66,"payload":{"rich_text":["hello"],"room":"42","typ":"chat"},"timestamp":3735928559,"user":"2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12"}}"#
]]; ]];
expect.assert_eq(&json); expect.assert_eq(&json);
let roundtrip_item = serde_json::from_str::<WithSig<ChatPayload>>(&json).unwrap(); let roundtrip_msg = serde_json::from_str::<WithSig<ChatPayload>>(&json).unwrap();
assert_eq!(roundtrip_item, item); assert_eq!(roundtrip_msg, msg);
roundtrip_item.verify().unwrap(); roundtrip_msg.verify().unwrap();
} }
#[test] #[test]

View file

@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS `room_member` (
`rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE, `rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE,
`uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT, `uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT,
`permission` INTEGER NOT NULL, `permission` INTEGER NOT NULL,
-- Optionally references `room_item`(`cid`). -- Optionally references `msg`(`cid`).
`last_seen_cid` INTEGER NOT NULL DEFAULT 0, `last_seen_cid` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (`rid`, `uid`) PRIMARY KEY (`rid`, `uid`)
) STRICT; ) STRICT;
@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS `room_member` (
CREATE INDEX IF NOT EXISTS `ix_member_room` ON `room_member` CREATE INDEX IF NOT EXISTS `ix_member_room` ON `room_member`
(`uid` ASC, `rid` ASC, `permission`, `last_seen_cid`); (`uid` ASC, `rid` ASC, `permission`, `last_seen_cid`);
CREATE TABLE IF NOT EXISTS `room_item` ( CREATE TABLE IF NOT EXISTS `msg` (
`cid` INTEGER NOT NULL PRIMARY KEY, `cid` INTEGER NOT NULL PRIMARY KEY,
`rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE, `rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE,
`uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT, `uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT,
@ -49,4 +49,4 @@ CREATE TABLE IF NOT EXISTS `room_item` (
`rich_text` TEXT NOT NULL `rich_text` TEXT NOT NULL
) STRICT; ) STRICT;
CREATE INDEX IF NOT EXISTS `room_latest_item` ON `room_item` (`rid` ASC, `cid` DESC); CREATE INDEX IF NOT EXISTS `room_latest_msg` ON `msg` (`rid` ASC, `cid` DESC);

View file

@ -10,7 +10,7 @@ static INIT_SQL: &str = include_str!("../schema.sql");
// Simple and stupid version check for now. // Simple and stupid version check for now.
// `echo -n 'blahd-database-0' | sha256sum | head -c5` || version // `echo -n 'blahd-database-0' | sha256sum | head -c5` || version
const APPLICATION_ID: i32 = 0xd9e_8403; const APPLICATION_ID: i32 = 0xd9e_8404;
#[derive(Debug)] #[derive(Debug)]
pub struct Database { pub struct Database {

View file

@ -8,7 +8,7 @@ use std::task::{Context, Poll};
use anyhow::{bail, Context as _, Result}; use anyhow::{bail, Context as _, Result};
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use blah_types::{AuthPayload, ChatItem, WithSig}; use blah_types::{AuthPayload, SignedChatMsg, WithSig};
use futures_util::future::Either; use futures_util::future::Either;
use futures_util::stream::SplitSink; use futures_util::stream::SplitSink;
use futures_util::{stream_select, SinkExt as _, Stream, StreamExt}; use futures_util::{stream_select, SinkExt as _, Stream, StreamExt};
@ -28,8 +28,8 @@ pub enum Incoming {}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Outgoing<'a> { pub enum Outgoing<'a> {
/// A chat message from a joined room. /// A message from a joined room.
Chat(&'a ChatItem), Msg(&'a SignedChatMsg),
/// The receiver is too slow to receive and some events and are dropped. /// The receiver is too slow to receive and some events and are dropped.
// FIXME: Should we indefinitely buffer them or just disconnect the client instead? // FIXME: Should we indefinitely buffer them or just disconnect the client instead?
Lagged, Lagged,
@ -71,11 +71,11 @@ impl WsSenderWrapper<'_, '_> {
} }
} }
type UserEventSender = broadcast::Sender<Arc<ChatItem>>; type UserEventSender = broadcast::Sender<Arc<SignedChatMsg>>;
#[derive(Debug)] #[derive(Debug)]
struct UserEventReceiver { struct UserEventReceiver {
rx: BroadcastStream<Arc<ChatItem>>, rx: BroadcastStream<Arc<SignedChatMsg>>,
st: Arc<AppState>, st: Arc<AppState>,
uid: u64, uid: u64,
} }
@ -93,7 +93,7 @@ impl Drop for UserEventReceiver {
} }
impl Stream for UserEventReceiver { impl Stream for UserEventReceiver {
type Item = Result<Arc<ChatItem>, BroadcastStreamRecvError>; type Item = Result<Arc<SignedChatMsg>, BroadcastStreamRecvError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.rx.poll_next_unpin(cx) self.rx.poll_next_unpin(cx)
@ -155,7 +155,7 @@ pub async fn handle_ws(st: Arc<AppState>, ws: &mut WebSocket) -> Result<Infallib
Either::Left(msg) => match serde_json::from_str::<Incoming>(&msg?)? {}, Either::Left(msg) => match serde_json::from_str::<Incoming>(&msg?)? {},
Either::Right(ret) => { Either::Right(ret) => {
let msg = match &ret { let msg = match &ret {
Ok(chat) => Outgoing::Chat(chat), Ok(chat) => Outgoing::Msg(chat),
Err(BroadcastStreamRecvError::Lagged(_)) => Outgoing::Lagged, Err(BroadcastStreamRecvError::Lagged(_)) => Outgoing::Lagged,
}; };
// TODO: Concurrent send. // TODO: Concurrent send.

View file

@ -11,9 +11,9 @@ use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use axum_extra::extract::WithRejection as R; use axum_extra::extract::WithRejection as R;
use blah_types::{ use blah_types::{
ChatItem, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, RoomAdminOp,
RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, Signee, UserKey, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, SignedChatMsg, Signee, UserKey,
WithItemId, WithSig, WithMsgId, WithSig,
}; };
use config::ServerConfig; use config::ServerConfig;
use ed25519_dalek::SIGNATURE_LENGTH; use ed25519_dalek::SIGNATURE_LENGTH;
@ -100,8 +100,8 @@ pub fn router(st: Arc<AppState>) -> Router {
.route("/room/:rid", get(room_get_metadata)) .route("/room/:rid", get(room_get_metadata))
// NB. Sync with `feed_url` and `next_url` generation. // NB. Sync with `feed_url` and `next_url` generation.
.route("/room/:rid/feed.json", get(room_get_feed)) .route("/room/:rid/feed.json", get(room_get_feed))
.route("/room/:rid/item", get(room_item_list).post(room_item_post)) .route("/room/:rid/msg", get(room_msg_list).post(room_msg_post))
.route("/room/:rid/item/:cid/seen", post(room_item_mark_seen)) .route("/room/:rid/msg/:cid/seen", post(room_msg_mark_seen))
.route("/room/:rid/admin", post(room_admin)) .route("/room/:rid/admin", post(room_admin))
.layer(tower_http::limit::RequestBodyLimitLayer::new( .layer(tower_http::limit::RequestBodyLimitLayer::new(
st.config.max_request_len, st.config.max_request_len,
@ -188,12 +188,12 @@ async fn room_list(
.query_map(params, |row| { .query_map(params, |row| {
// TODO: Extract this into a function. // TODO: Extract this into a function.
let rid = row.get("rid")?; let rid = row.get("rid")?;
let last_item = row let last_msg = row
.get::<_, Option<Id>>("cid")? .get::<_, Option<Id>>("cid")?
.map(|cid| { .map(|cid| {
Ok::<_, rusqlite::Error>(WithItemId { Ok::<_, rusqlite::Error>(WithMsgId {
cid, cid,
item: ChatItem { msg: SignedChatMsg {
sig: row.get("sig")?, sig: row.get("sig")?,
signee: Signee { signee: Signee {
nonce: row.get("nonce")?, nonce: row.get("nonce")?,
@ -212,7 +212,7 @@ async fn room_list(
rid, rid,
title: row.get("title")?, title: row.get("title")?,
attrs: row.get("attrs")?, attrs: row.get("attrs")?,
last_item, last_msg,
last_seen_cid: Some(row.get::<_, Id>("last_seen_cid")?) last_seen_cid: Some(row.get::<_, Id>("last_seen_cid")?)
.filter(|cid| cid.0 != 0), .filter(|cid| cid.0 != 0),
unseen_cnt: row.get("unseen_cnt").ok(), unseen_cnt: row.get("unseen_cnt").ok(),
@ -232,7 +232,7 @@ async fn room_list(
SELECT `rid`, `title`, `attrs`, 0 AS `last_seen_cid`, SELECT `rid`, `title`, `attrs`, 0 AS `last_seen_cid`,
`cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text` `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`
FROM `room` FROM `room`
LEFT JOIN `room_item` USING (`rid`) LEFT JOIN `msg` USING (`rid`)
LEFT JOIN `user` AS `last_author` USING (`uid`) LEFT JOIN `user` AS `last_author` USING (`uid`)
WHERE `rid` > :start_rid AND WHERE `rid` > :start_rid AND
(`attrs` & :perm) = :perm (`attrs` & :perm) = :perm
@ -257,8 +257,8 @@ async fn room_list(
FROM `user` FROM `user`
JOIN `room_member` USING (`uid`) JOIN `room_member` USING (`uid`)
JOIN `room` USING (`rid`) JOIN `room` USING (`rid`)
LEFT JOIN `room_item` USING (`rid`) LEFT JOIN `msg` USING (`rid`)
LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`) LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `msg`.`uid`)
LEFT JOIN `user` AS `peer_user` ON LEFT JOIN `user` AS `peer_user` ON
(`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`) (`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`)
WHERE `user`.`userkey` = :userkey AND WHERE `user`.`userkey` = :userkey AND
@ -283,14 +283,14 @@ async fn room_list(
`cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`, `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`,
`peer_user`.`userkey` AS `peer_userkey`, `peer_user`.`userkey` AS `peer_userkey`,
(SELECT COUNT(*) (SELECT COUNT(*)
FROM `room_item` AS `unseen_item` FROM `msg` AS `unseen_msg`
WHERE `unseen_item`.`rid` = `room`.`rid` AND WHERE `unseen_msg`.`rid` = `room`.`rid` AND
`last_seen_cid` < `unseen_item`.`cid`) AS `unseen_cnt` `last_seen_cid` < `unseen_msg`.`cid`) AS `unseen_cnt`
FROM `user` FROM `user`
JOIN `room_member` USING (`uid`) JOIN `room_member` USING (`uid`)
JOIN `room` USING (`rid`) JOIN `room` USING (`rid`)
LEFT JOIN `room_item` USING (`rid`) LEFT JOIN `msg` USING (`rid`)
LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`) LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `msg`.`uid`)
LEFT JOIN `user` AS `peer_user` ON LEFT JOIN `user` AS `peer_user` ON
(`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`) (`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`)
WHERE `user`.`userkey` = :userkey AND WHERE `user`.`userkey` = :userkey AND
@ -536,7 +536,7 @@ struct Pagination {
/// Maximum page size. /// Maximum page size.
top: Option<NonZeroUsize>, top: Option<NonZeroUsize>,
/// Only return items before (excluding) this token. /// Only return items before (excluding) this token.
/// Useful for `room_item_list` to pass `last_seen_cid` without over-fetching. /// Useful for `room_msg_list` to pass `last_seen_cid` without over-fetching.
until_token: Option<Id>, until_token: Option<Id>,
} }
@ -550,24 +550,24 @@ impl Pagination {
} }
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomItems { pub struct RoomMsgs {
pub items: Vec<WithItemId<ChatItem>>, pub msgs: Vec<WithMsgId<SignedChatMsg>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub skip_token: Option<Id>, pub skip_token: Option<Id>,
} }
async fn room_item_list( async fn room_msg_list(
st: ArcState, st: ArcState,
R(Path(rid), _): RE<Path<Id>>, R(Path(rid), _): RE<Path<Id>>,
R(Query(pagination), _): RE<Query<Pagination>>, R(Query(pagination), _): RE<Query<Pagination>>,
auth: MaybeAuth, auth: MaybeAuth,
) -> Result<Json<RoomItems>, ApiError> { ) -> Result<Json<RoomMsgs>, ApiError> {
let (items, skip_token) = { let (msgs, skip_token) = {
let conn = st.db.get(); let conn = st.db.get();
get_room_if_readable(&conn, rid, auth.into_optional()?.as_ref(), |_row| Ok(()))?; get_room_if_readable(&conn, rid, auth.into_optional()?.as_ref(), |_row| Ok(()))?;
query_room_items(&st, &conn, rid, pagination)? query_room_msgs(&st, &conn, rid, pagination)?
}; };
Ok(Json(RoomItems { items, skip_token })) Ok(Json(RoomMsgs { msgs, skip_token }))
} }
async fn room_get_metadata( async fn room_get_metadata(
@ -589,7 +589,7 @@ async fn room_get_metadata(
attrs, attrs,
// TODO: Should we include these here? // TODO: Should we include these here?
last_item: None, last_msg: None,
last_seen_cid: None, last_seen_cid: None,
unseen_cnt: None, unseen_cnt: None,
member_permission: None, member_permission: None,
@ -603,28 +603,28 @@ async fn room_get_feed(
R(Query(pagination), _): RE<Query<Pagination>>, R(Query(pagination), _): RE<Query<Pagination>>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let title; let title;
let (items, skip_token) = { let (msgs, skip_token) = {
let conn = st.db.get(); let conn = st.db.get();
title = get_room_if_readable(&conn, rid, None, |row| row.get::<_, String>("title"))?; title = get_room_if_readable(&conn, rid, None, |row| row.get::<_, String>("title"))?;
query_room_items(&st, &conn, rid, pagination)? query_room_msgs(&st, &conn, rid, pagination)?
}; };
let items = items let items = msgs
.into_iter() .into_iter()
.map(|WithItemId { cid, item }| { .map(|WithMsgId { cid, msg }| {
let time = SystemTime::UNIX_EPOCH + Duration::from_secs(item.signee.timestamp); let time = SystemTime::UNIX_EPOCH + Duration::from_secs(msg.signee.timestamp);
let author = FeedAuthor { let author = FeedAuthor {
name: item.signee.user.to_string(), name: msg.signee.user.to_string(),
}; };
FeedItem { FeedItem {
id: cid.to_string(), id: cid.to_string(),
content_html: item.signee.payload.rich_text.html().to_string(), content_html: msg.signee.payload.rich_text.html().to_string(),
date_published: humantime::format_rfc3339(time).to_string(), date_published: humantime::format_rfc3339(time).to_string(),
authors: (author,), authors: (author,),
extra: FeedItemExtra { extra: FeedItemExtra {
timestamp: item.signee.timestamp, timestamp: msg.signee.timestamp,
nonce: item.signee.nonce, nonce: msg.signee.nonce,
sig: item.sig, sig: msg.sig,
}, },
} }
}) })
@ -734,19 +734,19 @@ fn get_room_if_readable<T>(
}) })
} }
/// Get room items with pagination parameters, /// Get room messages with pagination parameters,
/// return a page of items and the next skip_token if this is not the last page. /// return a page of messages and the next `skip_token` if this is not the last page.
fn query_room_items( fn query_room_msgs(
st: &AppState, st: &AppState,
conn: &Connection, conn: &Connection,
rid: Id, rid: Id,
pagination: Pagination, pagination: Pagination,
) -> Result<(Vec<WithItemId<ChatItem>>, Option<Id>), ApiError> { ) -> Result<(Vec<WithMsgId<SignedChatMsg>>, Option<Id>), ApiError> {
let page_len = pagination.effective_page_len(st); let page_len = pagination.effective_page_len(st);
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
r" r"
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text` SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`
FROM `room_item` FROM `msg`
JOIN `user` USING (`uid`) JOIN `user` USING (`uid`)
WHERE `rid` = :rid AND WHERE `rid` = :rid AND
:after_cid < `cid` AND :after_cid < `cid` AND
@ -755,7 +755,7 @@ fn query_room_items(
LIMIT :limit LIMIT :limit
", ",
)?; )?;
let items = stmt let msgs = stmt
.query_and_then( .query_and_then(
named_params! { named_params! {
":rid": rid, ":rid": rid,
@ -764,9 +764,9 @@ fn query_room_items(
":limit": page_len, ":limit": page_len,
}, },
|row| { |row| {
Ok(WithItemId { Ok(WithMsgId {
cid: row.get("cid")?, cid: row.get("cid")?,
item: ChatItem { msg: SignedChatMsg {
sig: row.get("sig")?, sig: row.get("sig")?,
signee: Signee { signee: Signee {
nonce: row.get("nonce")?, nonce: row.get("nonce")?,
@ -783,12 +783,12 @@ fn query_room_items(
)? )?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
let skip_token = let skip_token =
(items.len() == page_len).then(|| items.last().expect("page must not be empty").cid); (msgs.len() == page_len).then(|| msgs.last().expect("page must not be empty").cid);
Ok((items, skip_token)) Ok((msgs, skip_token))
} }
async fn room_item_post( async fn room_msg_post(
st: ArcState, st: ArcState,
R(Path(rid), _): RE<Path<Id>>, R(Path(rid), _): RE<Path<Id>>,
SignedJson(chat): SignedJson<ChatPayload>, SignedJson(chat): SignedJson<ChatPayload>,
@ -836,14 +836,14 @@ async fn room_item_post(
return Err(error_response!( return Err(error_response!(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"permission_denied", "permission_denied",
"the user does not have permission to post item in the room", "the user does not have permission to post in the room",
)); ));
} }
let cid = Id::gen(); let cid = Id::gen();
conn.execute( conn.execute(
r" r"
INSERT INTO `room_item` (`cid`, `rid`, `uid`, `timestamp`, `nonce`, `sig`, `rich_text`) INSERT INTO `msg` (`cid`, `rid`, `uid`, `timestamp`, `nonce`, `sig`, `rich_text`)
VALUES (:cid, :rid, :uid, :timestamp, :nonce, :sig, :rich_text) VALUES (:cid, :rid, :uid, :timestamp, :nonce, :sig, :rich_text)
", ",
named_params! { named_params! {
@ -1048,7 +1048,7 @@ async fn room_leave(st: &AppState, rid: Id, user: UserKey) -> Result<(), ApiErro
Ok(()) Ok(())
} }
async fn room_item_mark_seen( async fn room_msg_mark_seen(
st: ArcState, st: ArcState,
R(Path((rid, cid)), _): RE<Path<(Id, u64)>>, R(Path((rid, cid)), _): RE<Path<(Id, u64)>>,
Auth(user): Auth, Auth(user): Auth,

View file

@ -7,11 +7,11 @@ use std::sync::{Arc, LazyLock};
use anyhow::Result; use anyhow::Result;
use blah_types::{ use blah_types::{
get_timestamp, AuthPayload, ChatItem, ChatPayload, CreateGroup, CreatePeerChat, get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id,
CreateRoomPayload, Id, MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMember,
RoomMember, RoomMemberList, RoomMetadata, ServerPermission, UserKey, WithItemId, WithSig, RoomMemberList, RoomMetadata, ServerPermission, SignedChatMsg, UserKey, WithMsgId, WithSig,
}; };
use blahd::{ApiError, AppState, Database, RoomItems, RoomList}; use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs};
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
use futures_util::TryFutureExt; use futures_util::TryFutureExt;
use rand::rngs::mock::StepRng; use rand::rngs::mock::StepRng;
@ -176,8 +176,8 @@ impl Server {
rid: Id, rid: Id,
key: &SigningKey, key: &SigningKey,
text: &str, text: &str,
) -> impl Future<Output = Result<WithItemId<ChatItem>>> + use<'_> { ) -> impl Future<Output = Result<WithMsgId<SignedChatMsg>>> + use<'_> {
let item = sign( let msg = sign(
key, key,
&mut *self.rng.borrow_mut(), &mut *self.rng.borrow_mut(),
ChatPayload { ChatPayload {
@ -189,13 +189,13 @@ impl Server {
let cid = self let cid = self
.request::<_, Id>( .request::<_, Id>(
Method::POST, Method::POST,
&format!("/room/{rid}/item"), &format!("/room/{rid}/msg"),
None, None,
Some(item.clone()), Some(msg.clone()),
) )
.await? .await?
.unwrap(); .unwrap();
Ok(WithItemId { cid, item }) Ok(WithMsgId { cid, msg })
} }
} }
} }
@ -263,7 +263,7 @@ async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] publ
} else { } else {
RoomAttrs::empty() RoomAttrs::empty()
}, },
last_item: None, last_msg: None,
last_seen_cid: None, last_seen_cid: None,
unseen_cnt: None, unseen_cnt: None,
member_permission: None, member_permission: None,
@ -406,7 +406,7 @@ async fn room_join_leave(server: Server, ref mut rng: impl RngCore) {
#[rstest] #[rstest]
#[tokio::test] #[tokio::test]
async fn room_item_post_read(server: Server, ref mut rng: impl RngCore) { async fn room_chat_post_read(server: Server, ref mut rng: impl RngCore) {
let rid_pub = server let rid_pub = server
.create_room( .create_room(
&ALICE_PRIV, &ALICE_PRIV,
@ -430,9 +430,9 @@ async fn room_item_post_read(server: Server, ref mut rng: impl RngCore) {
}, },
) )
}; };
let post = |rid: Id, chat: ChatItem| { let post = |rid: Id, chat: SignedChatMsg| {
server server
.request::<_, Id>(Method::POST, &format!("/room/{rid}/item"), None, Some(chat)) .request::<_, Id>(Method::POST, &format!("/room/{rid}/msg"), None, Some(chat))
.map_ok(|opt| opt.unwrap()) .map_ok(|opt| opt.unwrap())
}; };
@ -471,94 +471,88 @@ async fn room_item_post_read(server: Server, ref mut rng: impl RngCore) {
.await .await
.expect_api_err(StatusCode::NOT_FOUND, "not_found"); .expect_api_err(StatusCode::NOT_FOUND, "not_found");
//// Item listing //// //// Msgs listing ////
let chat1 = WithItemId::new(cid1, chat1); let chat1 = WithMsgId::new(cid1, chat1);
let chat2 = WithItemId::new(cid2, chat2); let chat2 = WithMsgId::new(cid2, chat2);
// List with default page size. // List with default page size.
let items = server let msgs = server
.get::<RoomItems>(&format!("/room/{rid_pub}/item"), None) .get::<RoomMsgs>(&format!("/room/{rid_pub}/msg"), None)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
items, msgs,
RoomItems { RoomMsgs {
items: vec![chat2.clone(), chat1.clone()], msgs: vec![chat2.clone(), chat1.clone()],
skip_token: None, skip_token: None,
}, },
); );
// List with small page size. // List with small page size.
let items = server let msgs = server
.get::<RoomItems>(&format!("/room/{rid_pub}/item?top=1"), None) .get::<RoomMsgs>(&format!("/room/{rid_pub}/msg?top=1"), None)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
items, msgs,
RoomItems { RoomMsgs {
items: vec![chat2.clone()], msgs: vec![chat2.clone()],
skip_token: Some(cid2), skip_token: Some(cid2),
}, },
); );
// Second page. // Second page.
let items = server let msgs = server
.get::<RoomItems>( .get::<RoomMsgs>(&format!("/room/{rid_pub}/msg?skipToken={cid2}&top=1"), None)
&format!("/room/{rid_pub}/item?skipToken={cid2}&top=1"),
None,
)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
items, msgs,
RoomItems { RoomMsgs {
items: vec![chat1.clone()], msgs: vec![chat1.clone()],
skip_token: Some(cid1), skip_token: Some(cid1),
}, },
); );
// No more. // No more.
let items = server let msgs = server
.get::<RoomItems>( .get::<RoomMsgs>(&format!("/room/{rid_pub}/msg?skipToken={cid1}&top=1"), None)
&format!("/room/{rid_pub}/item?skipToken={cid1}&top=1"),
None,
)
.await .await
.unwrap(); .unwrap();
assert_eq!(items, RoomItems::default()); assert_eq!(msgs, RoomMsgs::default());
//// Private room //// //// Private room ////
// Access without token. // Access without token.
server server
.get::<RoomItems>(&format!("/room/{rid_priv}/item"), None) .get::<RoomMsgs>(&format!("/room/{rid_priv}/msg"), None)
.await .await
.expect_api_err(StatusCode::NOT_FOUND, "not_found"); .expect_api_err(StatusCode::NOT_FOUND, "not_found");
// Not a member. // Not a member.
server server
.get::<RoomItems>( .get::<RoomMsgs>(
&format!("/room/{rid_priv}/item"), &format!("/room/{rid_priv}/msg"),
Some(&auth(&BOB_PRIV, rng)), Some(&auth(&BOB_PRIV, rng)),
) )
.await .await
.expect_api_err(StatusCode::NOT_FOUND, "not_found"); .expect_api_err(StatusCode::NOT_FOUND, "not_found");
// Ok. // Ok.
let items = server let msgs = server
.get::<RoomItems>( .get::<RoomMsgs>(
&format!("/room/{rid_priv}/item"), &format!("/room/{rid_priv}/msg"),
Some(&auth(&ALICE_PRIV, rng)), Some(&auth(&ALICE_PRIV, rng)),
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(items, RoomItems::default()); assert_eq!(msgs, RoomMsgs::default());
} }
#[rstest] #[rstest]
#[tokio::test] #[tokio::test]
async fn last_seen_item(server: Server, ref mut rng: impl RngCore) { async fn last_seen(server: Server, ref mut rng: impl RngCore) {
let title = "public room"; let title = "public room";
let attrs = RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE; let attrs = RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE;
let member_perm = MemberPermission::ALL; let member_perm = MemberPermission::ALL;
@ -571,7 +565,7 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
let alice_chat1 = server.post_chat(rid, &ALICE_PRIV, "alice1").await.unwrap(); let alice_chat1 = server.post_chat(rid, &ALICE_PRIV, "alice1").await.unwrap();
let alice_chat2 = server.post_chat(rid, &ALICE_PRIV, "alice2").await.unwrap(); let alice_chat2 = server.post_chat(rid, &ALICE_PRIV, "alice2").await.unwrap();
// 2 new items. // 2 new msgs.
let rooms = server let rooms = server
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng))) .get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng)))
.await .await
@ -583,7 +577,7 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
rid, rid,
title: Some(title.into()), title: Some(title.into()),
attrs, attrs,
last_item: Some(alice_chat2.clone()), last_msg: Some(alice_chat2.clone()),
last_seen_cid: None, last_seen_cid: None,
unseen_cnt: Some(2), unseen_cnt: Some(2),
member_permission: Some(member_perm), member_permission: Some(member_perm),
@ -596,7 +590,7 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
let seen = |key: &SigningKey, cid: Id| { let seen = |key: &SigningKey, cid: Id| {
server.request::<NoContent, NoContent>( server.request::<NoContent, NoContent>(
Method::POST, Method::POST,
&format!("/room/{rid}/item/{cid}/seen"), &format!("/room/{rid}/msg/{cid}/seen"),
Some(&auth(key, &mut *server.rng.borrow_mut())), Some(&auth(key, &mut *server.rng.borrow_mut())),
None, None,
) )
@ -605,7 +599,7 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
// Mark the first one seen. // Mark the first one seen.
seen(&ALICE_PRIV, alice_chat1.cid).await.unwrap(); seen(&ALICE_PRIV, alice_chat1.cid).await.unwrap();
// 1 new item. // 1 new msg.
let rooms = server let rooms = server
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng))) .get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng)))
.await .await
@ -617,7 +611,7 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
rid, rid,
title: Some(title.into()), title: Some(title.into()),
attrs, attrs,
last_item: Some(alice_chat2.clone()), last_msg: Some(alice_chat2.clone()),
last_seen_cid: Some(alice_chat1.cid), last_seen_cid: Some(alice_chat1.cid),
unseen_cnt: Some(1), unseen_cnt: Some(1),
member_permission: Some(member_perm), member_permission: Some(member_perm),
@ -627,7 +621,7 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
} }
); );
// Mark the second one seen. Now there is no new items. // Mark the second one seen. Now there is no new messages.
seen(&ALICE_PRIV, alice_chat2.cid).await.unwrap(); seen(&ALICE_PRIV, alice_chat2.cid).await.unwrap();
let rooms = server let rooms = server
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng))) .get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng)))
@ -635,7 +629,7 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
.unwrap(); .unwrap();
assert_eq!(rooms, RoomList::default()); assert_eq!(rooms, RoomList::default());
// Marking a seen item seen is a no-op. // Marking a seen message seen is a no-op.
seen(&ALICE_PRIV, alice_chat2.cid).await.unwrap(); seen(&ALICE_PRIV, alice_chat2.cid).await.unwrap();
let rooms = server let rooms = server
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng))) .get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng)))
@ -688,7 +682,7 @@ async fn peer_chat(server: Server, ref mut rng: impl RngCore) {
rid, rid,
title: None, title: None,
attrs: RoomAttrs::PEER_CHAT, attrs: RoomAttrs::PEER_CHAT,
last_item: None, last_msg: None,
last_seen_cid: None, last_seen_cid: None,
unseen_cnt: None, unseen_cnt: None,
member_permission: None, member_permission: None,

View file

@ -72,8 +72,8 @@ paths:
schema: schema:
type: string type: string
description: description:
The maximum number of items returned in each page. This is only an The maximum count of rooms returned in a single response. This is
advice and server can clamp it to a smaller value. only an advice and server can clamp it to a smaller value.
- name: skipToken - name: skipToken
in: query in: query
@ -107,7 +107,7 @@ paths:
/room/create: /room/create:
post: post:
summary: Create room summary: Create a room
description: description:
When `typ="create_room"`, create a multi-user room. When `typ="create_room"`, create a multi-user room.
@ -229,13 +229,13 @@ paths:
schema: schema:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
/room/{rid}/item: /room/{rid}/msg:
get: get:
summary: List items in room summary: List messages in a room
description: | description: |
Return items in reversed time order, up to `skipToken` items in a Return a list of messages in reversed server time order, up to length `top`
single response, from room {rid}. in a single response, from room {rid}.
The last (oldest) chat `cid` will be returned as `skipToken` in The last (oldest) message's `cid` will be returned as `skipToken` in
response, which can be used as query parameter for the next GET, to response, which can be used as query parameter for the next GET, to
repeatedly fetch more history. repeatedly fetch more history.
@ -251,8 +251,9 @@ paths:
schema: schema:
type: integer type: integer
description: | description: |
The maximum number of items to return. This is an advice and may be The number of items returned in a single response. This is
further clamped by the server. It must not be zero. an advice and may be further clamped by the server. It must not be
zero.
- name: skipToken - name: skipToken
in: query in: query
@ -267,7 +268,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/RoomItems' $ref: '#/components/schemas/RoomMsgs'
404: 404:
description: | description: |
@ -278,7 +279,7 @@ paths:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
post: post:
summary: Post item in room summary: Post a `Msg` into a room
requestBody: requestBody:
content: content:
application/json: application/json:
@ -291,7 +292,7 @@ paths:
application/json: application/json:
schema: schema:
type: string type: string
description: Newly created item `cid`. description: Newly created message id `cid`.
403: 403:
description: The user does not have permission to post in this room. description: The user does not have permission to post in this room.
@ -307,15 +308,16 @@ paths:
schema: schema:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
/room/{rid}/item/{cid}/seen: /room/{rid}/msg/{cid}/seen:
post: post:
summary: Mark item seen summary: Mark a message seen
description: | description: |
Mark item {cid} in room {rid} seen by the current user. Mark message {cid} and everything before it in room {rid} seen by the
current user.
Server may enforce that last seen item does not go backward. Marking Server may enforce that last seen message does not go backward. Marking
an older item seen or sending the same request multiple times can be a an older message seen or sending the same request multiple times can be
no-op. a no-op.
parameters: parameters:
- name: Authorization - name: Authorization
@ -398,8 +400,8 @@ components:
description: Room attributes bitset, see `RoomAttrs`. description: Room attributes bitset, see `RoomAttrs`.
type: integer type: integer
format: int64 format: int64
last_item: last_msg:
$ref: '#/components/schemas/WithItemId-WithSig-Chat' $ref: '#/components/schemas/WithMsgId-WithSig-Chat'
last_seen_cid: last_seen_cid:
description: The `cid` of the last chat being marked as seen. description: The `cid` of the last chat being marked as seen.
type: string type: string
@ -429,16 +431,16 @@ components:
type: integer type: integer
format: int64 format: int64
RoomItems: RoomMsgs:
type: object type: object
required: required:
- items - msgs
properties: properties:
items: msgs:
description: Room items in reversed server-received time order. description: Room messages in reversed server-received time order.
type: array type: array
items: items:
$ref: '#/components/schemas/WithItemId-WithSig-Chat' $ref: '#/components/schemas/WithMsgId-WithSig-Chat'
skip_token: skip_token:
description: The token for fetching the next page. description: The token for fetching the next page.
type: string type: string
@ -581,14 +583,14 @@ components:
timestamp: 1724966284 timestamp: 1724966284
user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7 user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7
WithItemId-WithSig-Chat: WithMsgId-WithSig-Chat:
allOf: allOf:
- $ref: '#/components/schemas/WithSig-Chat' - $ref: '#/components/schemas/WithSig-Chat'
- type: object - type: object
properties: properties:
cid: cid:
type: string type: string
description: An opaque server-specific item identifier. description: An opaque server-specific identifier.
WithSig-CreateRoom: WithSig-CreateRoom:
type: object type: object

View file

@ -186,15 +186,15 @@ async function enterRoom(rid) {
}); });
genAuthHeader() genAuthHeader()
.then(opts => fetch(`${serverUrl}/room/${rid}/item`, opts)) .then(opts => fetch(`${serverUrl}/room/${rid}/msg`, opts))
.then(async (resp) => { return [resp.status, await resp.json()]; }) .then(async (resp) => { return [resp.status, await resp.json()]; })
.then(async ([status, json]) => { .then(async ([status, json]) => {
if (status !== 200) throw new Error(`status ${status}: ${json.error.message}`); if (status !== 200) throw new Error(`status ${status}: ${json.error.message}`);
const { items } = json const { msgs } = json
items.reverse(); msgs.reverse();
for (const chat of items) { for (const msg of msgs) {
lastCid = chat.cid; lastCid = msg.cid;
await showChatMsg(chat); await showChatMsg(msg);
} }
log('---history---'); log('---history---');
}) })
@ -242,12 +242,12 @@ async function connectServer(newServerUrl) {
if (msg.chat.signee.payload.room === curRoom) { if (msg.chat.signee.payload.room === curRoom) {
await showChatMsg(msg.chat); await showChatMsg(msg.chat);
} else { } else {
console.log('ignore background room item'); console.log('ignore background room msg');
} }
} else if (msg.lagged !== undefined) { } else if (msg.lagged !== undefined) {
log('some events are dropped because of queue overflow') log('some events are dropped because of queue overflow')
} else { } else {
log(`unknown ws message: ${e.data}`); log(`unknown ws msg: ${e.data}`);
} }
}; };
@ -269,11 +269,11 @@ async function loadRoomList(autoJoin) {
const resp = await fetch(`${serverUrl}/room?filter=${filter}`, await genAuthHeader()) const resp = await fetch(`${serverUrl}/room?filter=${filter}`, await genAuthHeader())
const json = await resp.json() const json = await resp.json()
if (resp.status !== 200) throw new Error(`status ${resp.status}: ${json.error.message}`); if (resp.status !== 200) throw new Error(`status ${resp.status}: ${json.error.message}`);
for (const { rid, title, attrs, last_item, last_seen_cid } of json.rooms) { for (const { rid, title, attrs, last_msg, last_seen_cid } of json.rooms) {
const el = document.createElement('option'); const el = document.createElement('option');
el.value = rid; el.value = rid;
el.innerText = `${title} (rid=${rid}, attrs=${attrs})`; el.innerText = `${title} (rid=${rid}, attrs=${attrs})`;
if (last_item !== undefined && last_item.cid !== last_seen_cid) { if (last_msg !== undefined && last_msg.cid !== last_seen_cid) {
el.innerText += ' (unread)'; el.innerText += ' (unread)';
} }
targetEl.appendChild(el); targetEl.appendChild(el);
@ -381,7 +381,7 @@ async function postChat(text) {
} else { } else {
richText = [text]; richText = [text];
} }
await signAndPost(`${serverUrl}/room/${curRoom}/item`, { await signAndPost(`${serverUrl}/room/${curRoom}/msg`, {
// sorted fields. // sorted fields.
rich_text: richText, rich_text: richText,
room: curRoom, room: curRoom,
@ -398,7 +398,7 @@ async function postChat(text) {
async function markSeen() { async function markSeen() {
try { try {
const resp = await fetch(`${serverUrl}/room/${curRoom}/item/${lastCid}/seen`, { const resp = await fetch(`${serverUrl}/room/${curRoom}/msg/${lastCid}/seen`, {
method: 'POST', method: 'POST',
headers: (await genAuthHeader()).headers, headers: (await genAuthHeader()).headers,
}) })