Maintain room member's last seen item and fix docs

This commit is contained in:
oxalica 2024-09-06 02:52:31 -04:00
parent e98c9f8b3c
commit e74da2812b
6 changed files with 100 additions and 14 deletions

View file

@ -28,12 +28,12 @@ paths:
For "public", it returns all public rooms on the server. For "public", it returns all public rooms on the server.
For "joined", `Authorization` must be provided and it will return For "joined", `Authorization` must be provided and it will return
rooms user have joined. rooms user have joined.
page_len: top:
in: query in: query
description: description:
The maximum number of items returned in each page. This is only an The maximum number of items returned in each page. This is only an
advice and server can clamp it to a smaller value. advice and server can clamp it to a smaller value.
page_token: skipToken:
in: query in: query
description: description:
The page token returned from a previous list response to fetch the 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. should be included (as the same value) for each page fetch.
headers: headers:
Authorization: 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 required: false
schema: schema:
$ret: WithSig<AuthPayload> $ret: WithSig<AuthPayload>
@ -49,7 +49,7 @@ paths:
200: 200:
content: content:
application/json: application/json:
$ref: '#/components/schema/ListRoom' $ref: '#/components/schema/RoomList'
401: 401:
description: Missing or invalid Authorization header. description: Missing or invalid Authorization header.
content: content:
@ -193,6 +193,29 @@ paths:
application/json: application/json:
$ref: '#/components/schemas/ApiError' $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<AuthPayload>
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: /room/{rid}/admin:
post: post:
summary: Room management summary: Room management
@ -261,6 +284,8 @@ components:
type: int64 type: int64
last_chat: last_chat:
$ref: 'WithItemId<WithSig<ChatPayload>>' $ref: 'WithItemId<WithSig<ChatPayload>>'
last_seen_cid:
type: string
RoomMetadata: RoomMetadata:
type: object type: object

View file

@ -17,10 +17,13 @@ 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,
`last_seen_cid` INTEGER NOT NULL REFERENCES `room_item` (`cid`) ON DELETE NO ACTION
DEFAULT 0,
PRIMARY KEY (`rid`, `uid`) PRIMARY KEY (`rid`, `uid`)
) STRICT; ) 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` ( CREATE TABLE IF NOT EXISTS `room_item` (
`cid` INTEGER NOT NULL PRIMARY KEY, `cid` INTEGER NOT NULL PRIMARY KEY,

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_8402; const APPLICATION_ID: i32 = 0xd9e_8403;
#[derive(Debug)] #[derive(Debug)]
pub struct Database { pub struct Database {

View file

@ -19,7 +19,7 @@ use config::Config;
use database::Database; use database::Database;
use ed25519_dalek::SIGNATURE_LENGTH; use ed25519_dalek::SIGNATURE_LENGTH;
use id::IdExt; use id::IdExt;
use middleware::{ApiError, MaybeAuth, ResultExt as _, SignedJson}; use middleware::{ApiError, Auth, MaybeAuth, ResultExt as _, SignedJson};
use parking_lot::Mutex; use parking_lot::Mutex;
use rusqlite::{named_params, params, Connection, OptionalExtension, Row, ToSql}; use rusqlite::{named_params, params, Connection, OptionalExtension, Row, ToSql};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -147,7 +147,8 @@ async fn main_async(st: AppState) -> Result<()> {
.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_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)) .route("/room/:rid/admin", post(room_admin))
.with_state(st.clone()) .with_state(st.clone())
.layer(tower_http::limit::RequestBodyLimitLayer::new( .layer(tower_http::limit::RequestBodyLimitLayer::new(
@ -258,11 +259,14 @@ async fn room_list(
}) })
}) })
.transpose()?; .transpose()?;
let last_seen_cid =
Some(row.get::<_, Id>("last_seen_cid")?).filter(|cid| cid.0 != 0);
Ok(RoomMetadata { Ok(RoomMetadata {
rid, rid,
title, title,
attrs, attrs,
last_chat, last_chat,
last_seen_cid,
}) })
})? })?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@ -274,7 +278,7 @@ async fn room_list(
match params.filter { match params.filter {
ListRoomFilter::Public => query( ListRoomFilter::Public => query(
r" r"
SELECT `rid`, `title`, `attrs`, 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 `room_item` USING (`rid`)
@ -296,7 +300,7 @@ async fn room_list(
query( query(
r" r"
SELECT SELECT
`rid`, `title`, `attrs`, `rid`, `title`, `attrs`, `last_seen_cid`,
`cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text` `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`
FROM `user` FROM `user`
JOIN `room_member` USING (`uid`) JOIN `room_member` USING (`uid`)
@ -431,7 +435,7 @@ struct RoomItems {
skip_token: Option<Id>, skip_token: Option<Id>,
} }
async fn room_get_item( async fn room_item_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>>,
@ -463,6 +467,7 @@ async fn room_get_metadata(
title, title,
attrs, attrs,
last_chat: None, last_chat: None,
last_seen_cid: None,
})) }))
} }
@ -574,9 +579,11 @@ pub struct RoomMetadata {
pub title: String, pub title: String,
pub attrs: RoomAttrs, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub last_chat: Option<WithItemId<ChatItem>>, pub last_chat: Option<WithItemId<ChatItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_seen_cid: Option<Id>,
} }
fn get_room_if_readable<T>( fn get_room_if_readable<T>(
@ -660,7 +667,7 @@ fn query_room_items(
Ok((items, skip_token)) Ok((items, skip_token))
} }
async fn room_post_item( async fn room_item_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>,
@ -898,3 +905,33 @@ async fn room_leave(st: &AppState, rid: Id, user: UserKey) -> Result<(), ApiErro
txn.commit()?; txn.commit()?;
Ok(()) Ok(())
} }
async fn room_item_mark_seen(
st: ArcState,
R(Path((rid, cid)), _): RE<Path<(Id, u64)>>,
Auth(user): Auth,
) -> Result<StatusCode, ApiError> {
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)
}

View file

@ -77,6 +77,8 @@
<div> <div>
<label for="chat">chat:</label> <label for="chat">chat:</label>
<input type="text" id="chat" placeholder="message" /> <input type="text" id="chat" placeholder="message" />
<button id="mark-seen">mark history seen</button>
</div> </div>
</div> </div>
</body> </body>

View file

@ -10,6 +10,7 @@ let curRoom = null;
let ws = null; let ws = null;
let keypair = null; let keypair = null;
let defaultConfig = {}; let defaultConfig = {};
let lastCid = null;
function bufToHex(buf) { function bufToHex(buf) {
return [...new Uint8Array(buf)] return [...new Uint8Array(buf)]
@ -192,6 +193,7 @@ async function enterRoom(rid) {
const { items } = json const { items } = json
items.reverse(); items.reverse();
for (const chat of items) { for (const chat of items) {
lastCid = chat.cid;
await showChatMsg(chat); await showChatMsg(chat);
} }
log('---history---'); log('---history---');
@ -267,10 +269,13 @@ 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 } of json.rooms) { for (const { rid, title, attrs, last_chat, 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_chat !== undefined && last_chat.cid !== last_seen_cid) {
el.innerText += ' (unread)';
}
targetEl.appendChild(el); targetEl.appendChild(el);
} }
} catch (err) { } 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 (_) => { window.onload = async (_) => {
try { try {
const resp = await fetch('./default.json'); const resp = await fetch('./default.json');
@ -427,6 +445,7 @@ function onButtonClick(selector, handler) {
onButtonClick('#leave-room', leaveRoom); onButtonClick('#leave-room', leaveRoom);
onButtonClick('#regen-key', generateKeypair); onButtonClick('#regen-key', generateKeypair);
onButtonClick('#refresh-rooms', async () => await loadRoomList(true)); onButtonClick('#refresh-rooms', async () => await loadRoomList(true));
onButtonClick('#mark-seen', markSeen);
serverUrlInput.onchange = async (e) => { serverUrlInput.onchange = async (e) => {
await connectServer(e.target.value); await connectServer(e.target.value);