From e74da2812b5d25f499d21a417db3cde0a76158e2 Mon Sep 17 00:00:00 2001 From: oxalica Date: Fri, 6 Sep 2024 02:52:31 -0400 Subject: [PATCH] Maintain room member's last seen item and fix docs --- blahd/docs/webapi.yaml | 33 +++++++++++++++++++++++---- blahd/schema.sql | 5 ++++- blahd/src/database.rs | 2 +- blahd/src/main.rs | 51 ++++++++++++++++++++++++++++++++++++------ pages/index.html | 2 ++ pages/main.js | 21 ++++++++++++++++- 6 files changed, 100 insertions(+), 14 deletions(-) diff --git a/blahd/docs/webapi.yaml b/blahd/docs/webapi.yaml index 1a2b756..1064664 100644 --- a/blahd/docs/webapi.yaml +++ b/blahd/docs/webapi.yaml @@ -28,12 +28,12 @@ paths: For "public", it returns all public rooms on the server. For "joined", `Authorization` must be provided and it will return rooms user have joined. - page_len: + top: in: query description: The maximum number of items returned in each page. This is only an advice and server can clamp it to a smaller value. - page_token: + skipToken: in: query description: The page token returned from a previous list response to fetch the @@ -41,7 +41,7 @@ paths: should be included (as the same value) for each page fetch. headers: Authorization: - description: Proof of membership for private rooms. Required if `joined` is true. + description: Proof of membership for private rooms. Required if `filter=joined`. required: false schema: $ret: WithSig @@ -49,7 +49,7 @@ paths: 200: content: application/json: - $ref: '#/components/schema/ListRoom' + $ref: '#/components/schema/RoomList' 401: description: Missing or invalid Authorization header. content: @@ -193,6 +193,29 @@ paths: application/json: $ref: '#/components/schemas/ApiError' + /room/{rid}/item/{cid}/seen: + post: + summary: Mark item {cid} in room {rid} seen by the current user. + description: + Server will enforce that last seen item does not go backward. Marking + an older item seen or sending the same request multiple times will be a + no-op. + headers: + Authorization: + description: Proof of membership for private rooms. + schema: + $ret: WithSig + responses: + 204: + description: Operation completed. + 404: + description: | + Room does not exist or the user is not in the room. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + /room/{rid}/admin: post: summary: Room management @@ -261,6 +284,8 @@ components: type: int64 last_chat: $ref: 'WithItemId>' + last_seen_cid: + type: string RoomMetadata: type: object diff --git a/blahd/schema.sql b/blahd/schema.sql index 9fe5ea0..64765b7 100644 --- a/blahd/schema.sql +++ b/blahd/schema.sql @@ -17,10 +17,13 @@ CREATE TABLE IF NOT EXISTS `room_member` ( `rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE, `uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT, `permission` INTEGER NOT NULL, + `last_seen_cid` INTEGER NOT NULL REFERENCES `room_item` (`cid`) ON DELETE NO ACTION + DEFAULT 0, PRIMARY KEY (`rid`, `uid`) ) STRICT; -CREATE INDEX IF NOT EXISTS `member_room` ON `room_member` (`uid` ASC, `rid` ASC); +CREATE INDEX IF NOT EXISTS `ix_member_room` ON `room_member` + (`uid` ASC, `rid` ASC, `permission`, `last_seen_cid`); CREATE TABLE IF NOT EXISTS `room_item` ( `cid` INTEGER NOT NULL PRIMARY KEY, diff --git a/blahd/src/database.rs b/blahd/src/database.rs index 40a6c6d..2ac5143 100644 --- a/blahd/src/database.rs +++ b/blahd/src/database.rs @@ -10,7 +10,7 @@ static INIT_SQL: &str = include_str!("../schema.sql"); // Simple and stupid version check for now. // `echo -n 'blahd-database-0' | sha256sum | head -c5` || version -const APPLICATION_ID: i32 = 0xd9e_8402; +const APPLICATION_ID: i32 = 0xd9e_8403; #[derive(Debug)] pub struct Database { diff --git a/blahd/src/main.rs b/blahd/src/main.rs index 0e99291..7a6da24 100644 --- a/blahd/src/main.rs +++ b/blahd/src/main.rs @@ -19,7 +19,7 @@ use config::Config; use database::Database; use ed25519_dalek::SIGNATURE_LENGTH; use id::IdExt; -use middleware::{ApiError, MaybeAuth, ResultExt as _, SignedJson}; +use middleware::{ApiError, Auth, MaybeAuth, ResultExt as _, SignedJson}; use parking_lot::Mutex; use rusqlite::{named_params, params, Connection, OptionalExtension, Row, ToSql}; use serde::{Deserialize, Serialize}; @@ -147,7 +147,8 @@ async fn main_async(st: AppState) -> Result<()> { .route("/room/:rid", get(room_get_metadata)) // NB. Sync with `feed_url` and `next_url` generation. .route("/room/:rid/feed.json", get(room_get_feed)) - .route("/room/:rid/item", get(room_get_item).post(room_post_item)) + .route("/room/:rid/item", get(room_item_list).post(room_item_post)) + .route("/room/:rid/item/:cid/seen", post(room_item_mark_seen)) .route("/room/:rid/admin", post(room_admin)) .with_state(st.clone()) .layer(tower_http::limit::RequestBodyLimitLayer::new( @@ -258,11 +259,14 @@ async fn room_list( }) }) .transpose()?; + let last_seen_cid = + Some(row.get::<_, Id>("last_seen_cid")?).filter(|cid| cid.0 != 0); Ok(RoomMetadata { rid, title, attrs, last_chat, + last_seen_cid, }) })? .collect::, _>>()?; @@ -274,7 +278,7 @@ async fn room_list( match params.filter { ListRoomFilter::Public => query( r" - SELECT `rid`, `title`, `attrs`, + SELECT `rid`, `title`, `attrs`, 0 AS `last_seen_cid`, `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text` FROM `room` LEFT JOIN `room_item` USING (`rid`) @@ -296,7 +300,7 @@ async fn room_list( query( r" SELECT - `rid`, `title`, `attrs`, + `rid`, `title`, `attrs`, `last_seen_cid`, `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text` FROM `user` JOIN `room_member` USING (`uid`) @@ -431,7 +435,7 @@ struct RoomItems { skip_token: Option, } -async fn room_get_item( +async fn room_item_list( st: ArcState, R(Path(rid), _): RE>, R(Query(pagination), _): RE>, @@ -463,6 +467,7 @@ async fn room_get_metadata( title, attrs, last_chat: None, + last_seen_cid: None, })) } @@ -574,9 +579,11 @@ pub struct RoomMetadata { pub title: String, pub attrs: RoomAttrs, - /// Optional extra information. Only included by the global room list response. + /// Optional extra information. Only included by the room list response. #[serde(skip_serializing_if = "Option::is_none")] pub last_chat: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_seen_cid: Option, } fn get_room_if_readable( @@ -660,7 +667,7 @@ fn query_room_items( Ok((items, skip_token)) } -async fn room_post_item( +async fn room_item_post( st: ArcState, R(Path(rid), _): RE>, SignedJson(chat): SignedJson, @@ -898,3 +905,33 @@ async fn room_leave(st: &AppState, rid: Id, user: UserKey) -> Result<(), ApiErro txn.commit()?; Ok(()) } + +async fn room_item_mark_seen( + st: ArcState, + R(Path((rid, cid)), _): RE>, + Auth(user): Auth, +) -> Result { + let changed = st.db.get().execute( + r" + UPDATE `room_member` + SET `last_seen_cid` = MAX(`last_seen_cid`, :cid) + WHERE + `rid` = :rid AND + `uid` = (SELECT `uid` FROM `user` WHERE `userkey` = :userkey) + ", + named_params! { + ":cid": cid, + ":rid": rid, + ":userkey": user, + }, + )?; + + if changed != 1 { + return Err(error_response!( + StatusCode::NOT_FOUND, + "not_found", + "room does not exists or user is not a room member", + )); + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/pages/index.html b/pages/index.html index b4174f9..d63aa3a 100644 --- a/pages/index.html +++ b/pages/index.html @@ -77,6 +77,8 @@
+ +
diff --git a/pages/main.js b/pages/main.js index 3e93caf..10d5f17 100644 --- a/pages/main.js +++ b/pages/main.js @@ -10,6 +10,7 @@ let curRoom = null; let ws = null; let keypair = null; let defaultConfig = {}; +let lastCid = null; function bufToHex(buf) { return [...new Uint8Array(buf)] @@ -192,6 +193,7 @@ async function enterRoom(rid) { const { items } = json items.reverse(); for (const chat of items) { + lastCid = chat.cid; await showChatMsg(chat); } log('---history---'); @@ -267,10 +269,13 @@ async function loadRoomList(autoJoin) { const resp = await fetch(`${serverUrl}/room?filter=${filter}`, await genAuthHeader()) const json = await resp.json() if (resp.status !== 200) throw new Error(`status ${resp.status}: ${json.error.message}`); - for (const { rid, title, attrs } of json.rooms) { + for (const { rid, title, attrs, last_chat, last_seen_cid } of json.rooms) { const el = document.createElement('option'); el.value = rid; el.innerText = `${title} (rid=${rid}, attrs=${attrs})`; + if (last_chat !== undefined && last_chat.cid !== last_seen_cid) { + el.innerText += ' (unread)'; + } targetEl.appendChild(el); } } catch (err) { @@ -391,6 +396,19 @@ async function postChat(text) { } } +async function markSeen() { + try { + const resp = await fetch(`${serverUrl}/room/${curRoom}/item/${lastCid}/seen`, { + method: 'POST', + headers: (await genAuthHeader()).headers, + }) + if (!resp.ok) throw new Error(`status ${resp.status}: ${(await resp.json()).error.message}`); + log('seen') + } catch (err) { + log(`failed to mark seen: ${err}`) + } +} + window.onload = async (_) => { try { const resp = await fetch('./default.json'); @@ -427,6 +445,7 @@ function onButtonClick(selector, handler) { onButtonClick('#leave-room', leaveRoom); onButtonClick('#regen-key', generateKeypair); onButtonClick('#refresh-rooms', async () => await loadRoomList(true)); +onButtonClick('#mark-seen', markSeen); serverUrlInput.onchange = async (e) => { await connectServer(e.target.value);