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 "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<AuthPayload>
@ -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<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:
post:
summary: Room management
@ -261,6 +284,8 @@ components:
type: int64
last_chat:
$ref: 'WithItemId<WithSig<ChatPayload>>'
last_seen_cid:
type: string
RoomMetadata:
type: object

View file

@ -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,

View file

@ -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 {

View file

@ -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::<Result<Vec<_>, _>>()?;
@ -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<Id>,
}
async fn room_get_item(
async fn room_item_list(
st: ArcState,
R(Path(rid), _): RE<Path<Id>>,
R(Query(pagination), _): RE<Query<Pagination>>,
@ -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<WithItemId<ChatItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_seen_cid: Option<Id>,
}
fn get_room_if_readable<T>(
@ -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<Path<Id>>,
SignedJson(chat): SignedJson<ChatPayload>,
@ -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<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>
<label for="chat">chat:</label>
<input type="text" id="chat" placeholder="message" />
<button id="mark-seen">mark history seen</button>
</div>
</div>
</body>

View file

@ -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);