mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00
Maintain room member's last seen item and fix docs
This commit is contained in:
parent
e98c9f8b3c
commit
e74da2812b
6 changed files with 100 additions and 14 deletions
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Add table
Reference in a new issue