openvibers/openvibe.chat Commits

Activity: what happened to you (mentions, replies, reactions, invites, friends, roles) (#468)

OP OpenVibers committed 4 days ago · a06e8355c3 · 20 files, +915 −18

* Chat Activity: the feed of mentions, replies, reactions, invites, friends and roles

* Merge origin/main into chat-activity-feed

* Activity test names the routes it drives

* Activity is a 200 page signed out, and only the rows it shows are read

* A reaction needs membership; @mentions stop at a word boundary

* Merge remote-tracking branch 'origin/main' into chat-activity-feed

Changes

ddiff --git a/migrations/0061_chat_activity.sql b/migrations/0061_chat_activity.sql
nnew file mode 100644
iindex 0000000..9229a34
---- /dev/null
++++ b/migrations/0061_chat_activity.sql
@@@ -0,0 +1,52 @@
++-- phase: expand
++-- Activity (the bottom bar's fourth place): what happened to a person in Chat, newest first. Six kinds, each one
++-- written when it happens and each row naming who, what, where and when (server/chat/activity.js):
++--
++-- mention someone wrote @you in a room, a channel or a direct message
++-- reply someone answered one of your messages (rooms, channels, direct messages and global chat)
++-- reaction someone put a reaction on your message in a room or a channel
++-- server_invite someone invited you to a server
++-- friend_accept someone accepted your friend request
++-- role someone gave you a role in a server or a room
++--
++-- The actor's name is kept on the row (it is what they were called when it happened), the room, server,
++-- conversation and message ids let a reader go back to the place, and read_at is when the person has seen it.
++-- A reader still checks that the room is readable now (server/chat/activity.js): an activity is never a way
++-- around a kick, a block or a room that is gone.
++CREATE TABLE IF NOT EXISTS chat_activity (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL, -- the person it happened to
++ kind text COLLATE "C" NOT NULL,
++ actor_user_id bigint, -- who did it (NULL for a service)
++ actor_username text COLLATE "C",
++ actor_display_name text COLLATE "C",
++ actor_avatar_url text COLLATE "C",
++ room_id bigint REFERENCES rooms(id) ON DELETE CASCADE,
++ server_id bigint REFERENCES chat_servers(id) ON DELETE CASCADE,
++ conversation_id bigint REFERENCES dm_conversations(id) ON DELETE CASCADE,
++ message_id bigint, -- room_messages.id, dm_messages.id or chat_messages.id
++ parent_id bigint, -- the message that was answered
++ scope text COLLATE "C" NOT NULL DEFAULT 'none',
++ excerpt text COLLATE "C", -- enough of the message to know what it is about
++ reaction text COLLATE "C",
++ role text COLLATE "C",
++ invite_code text COLLATE "C",
++ place_label text COLLATE "C", -- where it happened, when it has no row to join
++ place_href text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ read_at text COLLATE "C",
++ CHECK (kind IN ('mention', 'reply', 'reaction', 'server_invite', 'friend_accept', 'role')),
++ CHECK (scope IN ('room', 'dm', 'global', 'server', 'friends', 'none'))
++);
++-- A person's feed, newest first (the cursor is the id).
++CREATE INDEX IF NOT EXISTS idx_chat_activity_user ON chat_activity (user_id, id DESC);
++-- The Activity dot: only what is not read yet.
++CREATE INDEX IF NOT EXISTS idx_chat_activity_unread ON chat_activity (user_id) WHERE read_at IS NULL;
++-- A reaction is one event per person and message: a changed reaction updates that row, taking it back removes it.
++CREATE INDEX IF NOT EXISTS idx_chat_activity_reaction ON chat_activity (user_id, message_id, actor_user_id) WHERE kind = 'reaction';
++
++-- A reply is a message that answers another one. The rooms and direct messages were the only places without it;
++-- global chat and channel chat have kept reply_to_id since the first migration. Expand only: older releases
++-- write and read the column as NULL.
++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS reply_to_id bigint REFERENCES room_messages(id) ON DELETE SET NULL;
++ALTER TABLE dm_messages ADD COLUMN IF NOT EXISTS reply_to_id bigint REFERENCES dm_messages(id) ON DELETE SET NULL;
ddiff --git a/server/app.js b/server/app.js
iindex cc5da74..a5d3bca 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -335,6 +335,9 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));
app.use('/api/emotes', limits.identify, limits.reads('chat.emotes.read', { skip: (req) => req.path.startsWith('/file/') }));
++ // What happened to you in Chat (server/chat/activity.js, migration 0060): its own API, not Live's chat.
++ app.use('/api/v1/activity', limits.identify, limits.reads('chat.activity.read'), require('./chat/activity-routes'));
++
// The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 571383d..a9b6c44 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -95,6 +95,8 @@ const EXPORTS = [
// Co-pilot rooms (S5): the polls they voted in, and their reactions to room messages.
['agent_room_votes.json', `SELECT poll_id, option_id, voted_at FROM agent_room_poll_votes WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['room_reactions.json', `SELECT message_id, reaction, created_at FROM room_message_reactions WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
++ // What happened to them in Chat, and what their own actions caused (server/chat/activity.js).
++ ['activity.json', `SELECT id, kind, actor_user_id, actor_username, room_id, server_id, conversation_id, message_id, parent_id, scope, excerpt, reaction, role, created_at, read_at FROM chat_activity WHERE user_id = ANY(@ids) OR actor_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
];
async function exportPart(d, subject) {
@@@ -144,6 +146,7 @@ const ERASE_AFTER_ROOMS = [
['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],
['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],
['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],
++ ['activity', 'DELETE FROM chat_activity WHERE user_id = ANY(@ids) OR actor_user_id = ANY(@ids)'],
['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
// Friends and conversation view preferences from the Chat app shell.
['dm_contacts', 'DELETE FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids)'],
ddiff --git a/server/chat/activity-routes.js b/server/chat/activity-routes.js
nnew file mode 100644
iindex 0000000..c19c44a
---- /dev/null
++++ b/server/chat/activity-routes.js
@@@ -0,0 +1,55 @@
++'use strict';
++/**
++ * The Activity API (server/chat/activity.js, migration 0061_chat_activity.sql). Signed in only; every person sees
++ * their own activity and nothing else, and each read applies their current access (a room they left, a server
++ * they were kicked from, a blocked person, a deleted message).
++ *
++ * GET /api/v1/activity?limit=&before=&kind=mention,reply newest first, a cursor, a kind filter
++ * → { items, next_before, unread }
++ * GET /api/v1/activity/unread → { unread }
++ * POST /api/v1/activity/read { ids: [1, 2] } or { all: 1 } → { ok, read, unread }
++ * POST /api/v1/activity/:id/read → { ok, read, unread }
++ */
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++const { requireAuth } = require('../auth/auth');
++const activity = require('./activity');
++
++const router = express.Router();
++
++router.get('/unread', requireAuth, async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ unread: await activity.unreadCount(req.user) });
++});
++
++router.post('/read', requireAuth, async (req, res) => {
++ const body = req.body || {};
++ const all = body.all === true || body.all === 1 || body.all === '1';
++ const ids = Array.isArray(body.ids) ? body.ids : (body.id == null ? [] : [body.id]);
++ if (!all && !ids.length) return res.status(422).json({ error: 'Say which activity to mark read, or all of it', code: 'activity.ids' });
++ const read = await activity.markRead(req.user, { all, ids });
++ res.json({ ok: true, read, unread: await activity.unreadCount(req.user) });
++});
++
++router.post('/:id/read', requireAuth, async (req, res) => {
++ const id = Number(req.params.id);
++ if (!Number.isInteger(id) || id <= 0) return res.status(422).json({ error: 'No such activity', code: 'activity.id' });
++ const read = await activity.markRead(req.user, { id });
++ if (!read) {
++ // Already read, or not this person's: the same answer, because an id must not say whose it is.
++ const unread = await activity.unreadCount(req.user);
++ return res.json({ ok: true, read: 0, unread });
++ }
++ res.json({ ok: true, read, unread: await activity.unreadCount(req.user) });
++});
++
++router.get('/', requireAuth, async (req, res) => {
++ const kind = String(req.query.kind || '').split(',').map((k) => k.trim()).filter(Boolean);
++ const bad = kind.filter((k) => !activity.KINDS.includes(k));
++ if (bad.length) return res.status(422).json({ error: `Unknown activity kind: ${bad[0]}`, code: 'activity.kind' });
++ const limit = Math.min(activity.MAX_LIMIT, Math.max(1, Number(req.query.limit) || 30));
++ const before = Number(req.query.before) || null;
++ const out = await activity.list(req.user, { before, limit, kind: kind.length ? kind : null });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ ...out, unread: await activity.unreadCount(req.user) });
++});
++
++module.exports = router;
ddiff --git a/server/chat/activity.js b/server/chat/activity.js
nnew file mode 100644
iindex 0000000..2348191
---- /dev/null
++++ b/server/chat/activity.js
@@@ -0,0 +1,392 @@
++'use strict';
++/**
++ * Activity: what happened to a person in Chat (migration 0061_chat_activity.sql). One table of rows, one row per
++ * event, written when the event happens and read newest first by the person it happened to:
++ *
++ * mention @you in a room, a channel, a direct message or global chat
++ * reply an answer to one of your messages in a room, a channel, a direct message or global chat
++ * reaction a reaction on your message in a room or a channel (room_message_reactions)
++ * server_invite someone invited you to a server
++ * friend_accept someone accepted your friend request
++ * role someone gave you a role in a server or a room
++ *
++ * A row names who (the actor's snapshot), what (kind, excerpt, reaction or role), where (ids, resolved to a link
++ * when it is read) and when. The read is not a thin table lookup: the person's current access is checked on every
++ * read, so a kick, a block or a deleted room never leaks a line through the feed, and a blocked person's activity
++ * is left out. Writes never fail the thing that caused them (a message, a reaction, a role): they are recorded
++ * after it, and a failure is logged and forgotten.
++ *
++ * The API is server/chat/activity-routes.js (GET /api/v1/activity, GET /api/v1/activity/unread,
++ * POST /api/v1/activity/read and POST /api/v1/activity/:id/read).
++ */
++const db = require('../db/database');
++const networkBlocks = require('./network-blocks');
++
++const KINDS = Object.freeze(['mention', 'reply', 'reaction', 'server_invite', 'friend_accept', 'role']);
++const REACTIONS = Object.freeze(['thumbsup', 'thumbsdown', 'heart', 'laugh', 'surprised', 'sad']);
++const HANDLE_RE = /(^|[^\w@])@([A-Za-z0-9_]{3,24})(?![\w])/g;
++const MAX_EXCERPT = 200;
++const DEFAULT_LIMIT = 30;
++const MAX_LIMIT = 100;
++const MAX_MENTIONS = 10;
++
++const handleSet = (text) => {
++ const out = new Set();
++ for (const m of String(text || '').matchAll(HANDLE_RE)) out.add(String(m[2]).toLowerCase());
++ return out;
++};
++const excerptOf = (text) => {
++ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
++ return s ? s.slice(0, MAX_EXCERPT) : null;
++};
++const samePerson = (a, b) => Number(a) > 0 && Number(a) === Number(b);
++const snapshot = (actor) => ({
++ id: Number(actor && actor.id) || null,
++ username: (actor && (actor.username || actor.handle)) || null,
++ display_name: (actor && (actor.display_name || actor.name || actor.username || actor.handle)) || null,
++ avatar_url: (actor && actor.avatar_url) || null,
++});
++
++/** One activity row. Skips a person acting on themselves. → the row id, or null. */
++async function record({ userId, kind, actor, roomId = null, serverId = null, conversationId = null, messageId = null,
++ parentId = null, scope = 'none', excerpt = null, reaction = null, role = null, inviteCode = null,
++ placeLabel = null, placeHref = null } = {}) {
++ const recipient = Number(userId) || 0;
++ if (!recipient || !KINDS.includes(kind)) return null;
++ const who = snapshot(actor);
++ if (who.id && samePerson(recipient, who.id)) return null;
++ try {
++ const r = await db.run(`INSERT INTO chat_activity
++ (user_id, kind, actor_user_id, actor_username, actor_display_name, actor_avatar_url, room_id, server_id,
++ conversation_id, message_id, parent_id, scope, excerpt, reaction, role, invite_code, place_label, place_href)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [recipient, kind, who.id, who.username, who.display_name, who.avatar_url, roomId ? Number(roomId) : null,
++ serverId ? Number(serverId) : null, conversationId ? Number(conversationId) : null,
++ messageId ? Number(messageId) : null, parentId ? Number(parentId) : null, scope,
++ excerptOf(excerpt), reaction, role, inviteCode, placeLabel, placeHref]);
++ return Number(r.lastInsertRowid) || null;
++ } catch (err) {
++ console.warn(`[Activity] ${kind} not recorded: ${err && err.message}`);
++ return null;
++ }
++}
++
++// ── Recording at the place where the thing happens ───────────────────────────
++
++/** @you in a room or a server channel: its members, never the author. */
++async function mentionsInRoom({ room, message, actor }) {
++ const handles = handleSet(message && message.message);
++ if (!room || !message || !handles.size || !actor) return;
++ try {
++ const marks = [...handles].map(() => '?').join(',');
++ const rows = await db.all(`SELECT DISTINCT u.id, u.username, u.display_name, u.avatar_url
++ FROM room_members m JOIN ctx_users u ON u.id = m.user_id
++ WHERE m.room_id = ? AND u.id != ? AND lower(u.username) IN (${marks}) LIMIT ?`,
++ [room.id, actor.id, ...handles, MAX_MENTIONS]);
++ for (const p of rows) {
++ if (await networkBlocks.eitherBlockedUsers(actor.id, p.id)) continue;
++ await record({ userId: p.id, kind: 'mention', actor, roomId: room.id, messageId: message.id, scope: 'room', excerpt: message.message });
++ }
++ } catch (err) {
++ console.warn(`[Activity] room mention: ${err && err.message}`);
++ }
++}
++
++/** A reply in a room or a server channel. `parent` is the message being answered. */
++async function replyInRoom({ room, message, parent, actor }) {
++ if (!room || !message || !parent || !actor || !parent.user_id) return;
++ await record({ userId: parent.user_id, kind: 'reply', actor, roomId: room.id, messageId: message.id, parentId: parent.id, scope: 'room', excerpt: message.message });
++}
++
++/** @you in a direct message: the conversation's people, never the author. */
++async function mentionsInDm({ conversationId, message, actor }) {
++ const handles = handleSet(message && message.message);
++ if (!conversationId || !message || !handles.size || !actor) return;
++ try {
++ const rows = await db.all(`SELECT u.id, u.username, u.display_name, u.avatar_url
++ FROM dm_participants p JOIN ctx_users u ON u.id = p.user_id
++ WHERE p.conversation_id = ? AND u.id != ? LIMIT 20`, [conversationId, actor.id]);
++ let n = 0;
++ for (const p of rows) {
++ if (!handles.has(String(p.username || '').toLowerCase())) continue;
++ if (await networkBlocks.eitherBlockedUsers(actor.id, p.id)) continue;
++ await record({ userId: p.id, kind: 'mention', actor, conversationId, messageId: message.id, scope: 'dm', excerpt: message.message });
++ if (++n >= MAX_MENTIONS) break;
++ }
++ } catch (err) {
++ console.warn(`[Activity] dm mention: ${err && err.message}`);
++ }
++}
++
++/** A reply in a direct message. `parent` is the message being answered. */
++async function replyInDm({ conversationId, message, parent, actor }) {
++ if (!conversationId || !message || !parent || !actor || !parent.sender_id) return;
++ await record({ userId: parent.sender_id, kind: 'reply', actor, conversationId, messageId: message.id, parentId: parent.id, scope: 'dm', excerpt: message.message });
++}
++
++/** Where a global/channel chat line opens: the site's global room, or the channel on Live. */
++async function chatPlace(client) {
++ const ctx = require('../live-context');
++ try {
++ let ownerId = client && client.channelUserId;
++ if (!ownerId && client && client.streamId) ownerId = (await ctx.getStreamById(client.streamId) || {}).user_id;
++ const owner = ownerId ? await ctx.getUserById(ownerId) : null;
++ if (owner && owner.username) {
++ return { label: `${owner.display_name || owner.username}'s chat`, href: `https://openvibe.live/@${encodeURIComponent(owner.username)}` };
++ }
++ } catch { /* the global room is still a place */ }
++ return { label: 'Global chat', href: '/' };
++}
++
++/** @you in global or channel chat. */
++async function mentionsInChat({ client, message }) {
++ const handles = handleSet(message && message.message);
++ if (!client || !client.user || !client.user.id || !message || !message.id || !handles.size) return;
++ const place = await chatPlace(client);
++ const ctx = require('../live-context');
++ let n = 0;
++ for (const handle of handles) {
++ if (n >= MAX_MENTIONS) break;
++ try {
++ // The ctx projection only: a chat message never waits on Live for a mention.
++ const p = await ctx.getUserByUsername(handle);
++ if (!p || samePerson(p.id, client.user.id)) continue;
++ if (await networkBlocks.eitherBlockedUsers(client.user.id, p.id)) continue;
++ await record({ userId: p.id, kind: 'mention', actor: client.user, messageId: message.id, scope: 'global', excerpt: message.message, placeLabel: place.label, placeHref: place.href });
++ n += 1;
++ } catch { /* one unknown handle never stops the rest */ }
++ }
++}
++
++/** A reply in global or channel chat (chat_messages.reply_to_id). */
++async function replyInChat({ client, message, parent }) {
++ if (!client || !client.user || !client.user.id || !message || !message.id || !parent || !parent.user_id) return;
++ if (samePerson(parent.user_id, client.user.id)) return;
++ const place = await chatPlace(client);
++ await record({ userId: parent.user_id, kind: 'reply', actor: client.user, messageId: message.id, parentId: parent.id, scope: 'global', excerpt: message.message, placeLabel: place.label, placeHref: place.href });
++}
++
++/** A reaction on a room message. One row per person and message: changing it updates, taking it back removes. */
++async function reaction({ room, message, actor, reaction: kind }) {
++ if (!room || !message || !actor || !message.user_id || !REACTIONS.includes(kind)) return;
++ const recipient = Number(message.user_id);
++ if (!recipient || samePerson(recipient, actor.id)) return;
++ try {
++ const existing = await db.get("SELECT id FROM chat_activity WHERE user_id = ? AND kind = 'reaction' AND message_id = ? AND actor_user_id = ?",
++ [recipient, message.id, actor.id]);
++ if (existing) {
++ await db.run('UPDATE chat_activity SET reaction = ?, excerpt = ?, created_at = ov_now(), read_at = NULL WHERE id = ?', [kind, excerptOf(message.message), existing.id]);
++ } else {
++ await record({ userId: recipient, kind: 'reaction', actor, roomId: room.id, messageId: message.id, scope: 'room', excerpt: message.message, reaction: kind });
++ }
++ } catch (err) {
++ console.warn(`[Activity] reaction: ${err && err.message}`);
++ }
++}
++
++/** The reaction was taken back: its activity goes with it. */
++async function removeReaction({ message, actor }) {
++ if (!message || !actor) return;
++ try { await db.run("DELETE FROM chat_activity WHERE kind = 'reaction' AND message_id = ? AND actor_user_id = ?", [message.id, actor.id]); } catch { /* best effort */ }
++}
++
++/** Someone invited this person to their server (a single-use invite, server/rooms/servers.js invitePerson). */
++async function serverInvite({ userId, actor, server, code }) {
++ if (!server) return;
++ await record({ userId, kind: 'server_invite', actor, serverId: server.id, scope: 'server', inviteCode: code || null, excerpt: server.name });
++}
++
++/** Someone accepted this person's friend request. The actor is the person who accepted. */
++async function friendAccept({ userId, actor }) {
++ await record({ userId, kind: 'friend_accept', actor, scope: 'friends' });
++}
++
++/** A role given in a server or a room. `targetUserId` is the person who now has it. */
++async function roleGiven({ targetUserId, actor, role, serverId = null, roomId = null }) {
++ if (!role || role === 'blocked' || role === 'none') return;
++ await record({ userId: targetUserId, kind: 'role', actor, role, serverId, roomId, scope: serverId ? 'server' : 'room', excerpt: role });
++}
++
++// ── Reading: the access checks ────────────────────────────────────────────────
++
++/**
++ * The current-access clauses for a reader, on alias `a`: their room membership (and server membership unless
++ * blocked), their DM participation, a server that still exists, a message that was not deleted, and no block
++ * between them and the actor (Network blocks, one way, and Chat's own DM blocks, both ways).
++ * → { sql, params } whose placeholders follow the caller's own in the same order.
++ */
++function visibleTo(user) {
++ const me = Number(user && user.id) || 0;
++ const clauses = [];
++ const params = [];
++ if (user && user.subject_id) {
++ clauses.push(`NOT EXISTS (SELECT 1 FROM network_blocks nb JOIN ctx_users au ON au.subject_id = nb.blocked_subject
++ WHERE nb.blocker_subject = ? AND nb.active = 1 AND au.id = a.actor_user_id)`);
++ params.push(user.subject_id);
++ }
++ clauses.push(`NOT EXISTS (SELECT 1 FROM dm_blocks b WHERE (b.blocker_id = ? AND b.blocked_id = a.actor_user_id)
++ OR (b.blocked_id = ? AND b.blocker_id = a.actor_user_id))`);
++ params.push(me, me);
++ clauses.push(`(a.room_id IS NULL OR (EXISTS (SELECT 1 FROM rooms r WHERE r.id = a.room_id AND r.archived_at IS NULL)
++ AND EXISTS (SELECT 1 FROM room_members rm WHERE rm.room_id = a.room_id AND rm.user_id = ?)
++ AND (EXISTS (SELECT 1 FROM rooms r2 WHERE r2.id = a.room_id AND r2.server_id IS NULL)
++ OR EXISTS (SELECT 1 FROM rooms r3 JOIN chat_server_members sm ON sm.server_id = r3.server_id
++ WHERE r3.id = a.room_id AND sm.user_id = ? AND sm.role != 'blocked'))))`);
++ params.push(me, me);
++ clauses.push(`(a.conversation_id IS NULL OR EXISTS (SELECT 1 FROM dm_participants p WHERE p.conversation_id = a.conversation_id AND p.user_id = ?))`);
++ params.push(me);
++ clauses.push(`(a.server_id IS NULL OR EXISTS (SELECT 1 FROM chat_servers s WHERE s.id = a.server_id AND s.archived_at IS NULL))`);
++ clauses.push(`(a.message_id IS NULL OR a.scope NOT IN ('room', 'global', 'dm')
++ OR (a.scope = 'room' AND EXISTS (SELECT 1 FROM room_messages m WHERE m.id = a.message_id AND m.is_deleted = 0))
++ OR (a.scope = 'global' AND EXISTS (SELECT 1 FROM chat_messages cm WHERE cm.id = a.message_id AND cm.is_deleted = 0))
++ OR (a.scope = 'dm' AND EXISTS (SELECT 1 FROM dm_messages d WHERE d.id = a.message_id)))`);
++ return { sql: clauses.map((c) => `(${c})`).join(' AND '), params };
++}
++
++const isoOf = (ts) => {
++ const s = String(ts || '');
++ if (!s) return null;
++ const d = new Date(s.includes('T') ? s : `${s.replace(' ', 'T')}Z`);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++};
++
++/** The labels of the direct conversations in a page: the group's name, else the other people's names. */
++async function dmLabels(userId, ids) {
++ const out = new Map();
++ const list = [...new Set(ids.map(Number).filter((n) => n > 0))];
++ if (!list.length) return out;
++ const marks = list.map(() => '?').join(',');
++ const rows = await db.all(`SELECT p.conversation_id, u.username, u.display_name
++ FROM dm_participants p LEFT JOIN ctx_users u ON u.id = p.user_id
++ WHERE p.conversation_id IN (${marks}) AND p.user_id != ?`, [...list, Number(userId)]);
++ for (const r of rows) {
++ if (!out.has(Number(r.conversation_id))) out.set(Number(r.conversation_id), []);
++ const name = r.display_name || r.username;
++ if (name) out.get(Number(r.conversation_id)).push(name);
++ }
++ return out;
++}
++
++/** One row from the database as the API and the page want it: who, what, where, when. */
++function shape(r, dmNames) {
++ const actor = {
++ id: r.actor_user_id == null ? null : Number(r.actor_user_id),
++ username: r.actor_username || null,
++ display_name: r.actor_display_name || r.actor_username || null,
++ avatar_url: r.actor_avatar_url || null,
++ };
++ let where;
++ if (r.room_id) {
++ const inServer = r.room_server_slug && r.channel_slug;
++ where = {
++ kind: 'room',
++ label: r.room_name ? `#${r.room_name}${r.room_server_name ? ` in ${r.room_server_name}` : ''}` : (r.place_label || 'A room'),
++ href: inServer ? `/s/${r.room_server_slug}/${r.channel_slug}` : (r.room_slug ? `/r/${r.room_slug}` : '/rooms'),
++ room: r.room_slug || null,
++ server: r.room_server_slug || null,
++ };
++ } else if (r.conversation_id) {
++ const names = dmNames.get(Number(r.conversation_id)) || [];
++ where = {
++ kind: 'dm',
++ label: r.conversation_name || names.join(', ') || 'A direct message',
++ href: `/messages/${Number(r.conversation_id)}`,
++ conversation: Number(r.conversation_id),
++ };
++ } else if (r.server_id && r.scope === 'server') {
++ where = {
++ kind: 'server',
++ label: r.server_name || r.place_label || 'A server',
++ href: r.invite_code ? `/join/${encodeURIComponent(r.invite_code)}` : (r.server_slug ? `/s/${r.server_slug}` : '/servers'),
++ server: r.server_slug || null,
++ };
++ } else if (r.scope === 'friends') {
++ where = { kind: 'friends', label: 'Friends', href: '/messages' };
++ } else {
++ where = { kind: 'global', label: r.place_label || 'Global chat', href: r.place_href || '/' };
++ }
++ return {
++ id: Number(r.id),
++ kind: r.kind,
++ actor,
++ where,
++ what: {
++ excerpt: r.excerpt || null,
++ reaction: r.reaction || null,
++ role: r.role || null,
++ },
++ at: isoOf(r.created_at) || r.created_at,
++ read: !!r.read_at,
++ };
++}
++
++/** One page of a person's activity, newest first. `kind` is one kind or a list. → { items, next_before } */
++async function list(user, { before = null, limit = DEFAULT_LIMIT, kind = null } = {}) {
++ const me = Number(user && user.id) || 0;
++ if (!me) return { items: [], next_before: null };
++ const lim = Math.min(MAX_LIMIT, Math.max(1, Number(limit) || DEFAULT_LIMIT));
++ const kinds = [].concat(kind == null ? [] : kind).map((k) => String(k)).filter((k) => KINDS.includes(k));
++ const vis = visibleTo(user);
++ const params = [me];
++ let sql = `SELECT a.id, a.kind, a.actor_user_id, a.actor_username, a.actor_display_name, a.actor_avatar_url,
++ a.room_id, a.server_id, a.conversation_id, a.message_id, a.parent_id, a.scope, a.excerpt, a.reaction,
++ a.role, a.invite_code, a.place_label, a.place_href, a.created_at, a.read_at,
++ r.slug AS room_slug, r.name AS room_name, r.channel_slug,
++ rs.slug AS room_server_slug, rs.name AS room_server_name,
++ s.slug AS server_slug, s.name AS server_name,
++ c.name AS conversation_name
++ FROM chat_activity a
++ LEFT JOIN rooms r ON r.id = a.room_id
++ LEFT JOIN chat_servers rs ON rs.id = r.server_id
++ LEFT JOIN chat_servers s ON s.id = a.server_id
++ LEFT JOIN dm_conversations c ON c.id = a.conversation_id
++ WHERE a.user_id = ?`;
++ if (Number(before) > 0) { sql += ' AND a.id < ?'; params.push(Number(before)); }
++ if (kinds.length) { sql += ` AND a.kind IN (${kinds.map(() => '?').join(',')})`; params.push(...kinds); }
++ sql += ` AND ${vis.sql} ORDER BY a.id DESC LIMIT ?`;
++ params.push(...vis.params, lim + 1);
++ const rows = await db.all(sql, params);
++ const page = rows.slice(0, lim);
++ const dmNames = await dmLabels(me, page.filter((r) => r.conversation_id).map((r) => r.conversation_id));
++ return {
++ items: page.map((r) => shape(r, dmNames)),
++ next_before: rows.length > lim ? Number(page[page.length - 1].id) : null,
++ };
++}
++
++/** How many activities this person has not read yet, under the same access checks. */
++async function unreadCount(user) {
++ const me = Number(user && user.id) || 0;
++ if (!me) return 0;
++ const vis = visibleTo(user);
++ try {
++ const row = await db.get(`SELECT COUNT(*) AS n FROM chat_activity a WHERE a.user_id = ? AND a.read_at IS NULL AND ${vis.sql}`, [me, ...vis.params]);
++ return Number(row && row.n) || 0;
++ } catch (err) {
++ console.warn(`[Activity] unread: ${err && err.message}`);
++ return 0;
++ }
++}
++
++/** Mark a person's activity read: one row by id, or several, or all of it. → how many rows changed. */
++async function markRead(user, { id = null, ids = null, all = false } = {}) {
++ const me = Number(user && user.id) || 0;
++ if (!me) return 0;
++ if (all) {
++ const r = await db.run('UPDATE chat_activity SET read_at = ov_now() WHERE user_id = ? AND read_at IS NULL', [me]);
++ return r.changes || 0;
++ }
++ const list = [...new Set([].concat(id == null ? [] : [id], Array.isArray(ids) ? ids : []).map(Number).filter((n) => Number.isInteger(n) && n > 0))].slice(0, 500);
++ if (!list.length) return 0;
++ const r = await db.run(`UPDATE chat_activity SET read_at = ov_now() WHERE user_id = ? AND read_at IS NULL AND id IN (${list.map(() => '?').join(',')})`, [me, ...list]);
++ return r.changes || 0;
++}
++
++module.exports = {
++ KINDS, REACTIONS, MAX_LIMIT,
++ record,
++ mentionsInRoom, replyInRoom, mentionsInDm, replyInDm, mentionsInChat, replyInChat,
++ reaction, removeReaction, serverInvite, friendAccept, roleGiven,
++ list, unreadCount, markRead,
++ _handleSet: handleSet, _visibleTo: visibleTo,
++};
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 448def7..a8951e3 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -1201,6 +1201,14 @@ class ChatServer {
// Attach reply context to broadcast
if (replyTo) chatMsg.reply_to = replyTo;
++ // Activity (server/chat/activity.js): the person this answers, and anyone @named. A chat line is never
++ // held up by its activity; every write there is best effort.
++ if (client.user && client.user.id) {
++ const activity = require('./activity');
++ if (replyTo) await activity.replyInChat({ client, message: chatMsg, parent: replyTo });
++ await activity.mentionsInChat({ client, message: chatMsg });
++ }
++
// Live's reactions to a real chat line — OpenCoins chat bonus (logged-in users in a
// stream), AI chat viewers (stream chat), the PowerChat overlay relay (any channel chat) —
// are ONE call to Live per message; the coin result comes back for this socket.
ddiff --git a/server/chat/contacts.js b/server/chat/contacts.js
iindex 783329c..5401521 100644
---- a/server/chat/contacts.js
++++ b/server/chat/contacts.js
@@@ -67,6 +67,12 @@ async function accept(userId, contactId) {
if (!mine || mine.status !== 'incoming') return false;
await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [a, b]);
await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [b, a]);
++ // The person who asked hears that it was accepted (server/chat/activity.js friend_accept): the actor is the
++ // person who accepted.
++ try {
++ const actor = await require('../live-context').getUserById(a);
++ if (actor) await require('./activity').friendAccept({ userId: b, actor });
++ } catch { /* a contact change is never held up by its activity */ }
return true;
}
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
iindex 423df18..99a67f2 100644
---- a/server/chat/dm-routes.js
++++ b/server/chat/dm-routes.js
@@@ -256,7 +256,7 @@ router.post('/conversations/:id/messages', sendLimit, async (req, res) => {
return res.status(400).json({ error: 'Message required' });
}
-- const msg = publicMessage(await dm.sendMessage(convId, req.user.id, message));
++ const msg = publicMessage(await dm.sendMessage(convId, req.user.id, message, { reply_to_id: req.body && req.body.reply_to_id }));
if (!msg) return res.status(400).json({ error: 'Failed to send' });
// Attach sender info
ddiff --git a/server/chat/dm.js b/server/chat/dm.js
iindex 9e202dc..a443b16 100644
---- a/server/chat/dm.js
++++ b/server/chat/dm.js
@@@ -162,16 +162,22 @@ async function getConversation(conversationId) {
/**
* Send a message in a conversation. Returns the message row.
*/
--async function sendMessage(conversationId, senderId, text) {
++async function sendMessage(conversationId, senderId, text, { reply_to_id = null } = {}) {
if (!text || !text.trim()) return null;
const trimmed = text.trim().slice(0, 2000); // max 2000 chars
const senderSubject = await db.subjectFor(senderId);
++ // What this answers: it must be a message of this same conversation.
++ const replyToId = Number(reply_to_id) || null;
++ const replyTo = replyToId
++ ? await db.get('SELECT id, sender_id, message FROM dm_messages WHERE id = ? AND conversation_id = ?', [replyToId, conversationId])
++ : null;
++ if (replyToId && !replyTo) return null;
// A known OpenVibe link's card is resolved when the message is sent, with a second's budget (link-cards.js).
const { card, pending } = await linkCards.forSend(trimmed);
const row = await db.tx(async () => {
const result = await db.run(
-- `INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id, card) VALUES (?, ?, ?, ?, ?)`,
-- [conversationId, senderId, trimmed, senderSubject, linkCards.toJson(card)]
++ `INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id, card, reply_to_id) VALUES (?, ?, ?, ?, ?, ?)`,
++ [conversationId, senderId, trimmed, senderSubject, linkCards.toJson(card), replyToId]
);
// Touch conversation updated_at
await db.run(`UPDATE dm_conversations SET updated_at = ov_now() WHERE id = ?`, [conversationId]);
@@@ -197,6 +203,13 @@ async function sendMessage(conversationId, senderId, text) {
if (row) {
row.card = card;
if (pending) linkCards.fillLater('dm_messages', row.id, pending);
++ // Activity: @mentions in the conversation, and the person whose message this answers (server/chat/activity.js).
++ const activity = require('./activity');
++ const sender = senderId ? await require('../live-context').getUserById(senderId) : null;
++ if (sender) {
++ await activity.mentionsInDm({ conversationId, message: row, actor: sender });
++ if (replyTo) await activity.replyInDm({ conversationId, message: row, parent: replyTo, actor: sender });
++ }
}
return row;
}
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 98ab649..928df54 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -268,7 +268,7 @@ async function announceDeleted(room, ids) {
}
}
--async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, files = [] } = {}) {
++async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [] } = {}) {
const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
@@@ -297,6 +297,13 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
if (open) await polls.castVote({ poll: open, user, optionId: body.slice(1) });
} catch { /* a vote that does not count is never a failed message */ }
}
++ // What this answers, when the client sends a reply_to_id. It must be a live message of this same room.
++ const replyToId = Number(reply_to_id) || null;
++ let replyTo = null;
++ if (replyToId) {
++ replyTo = await db.get('SELECT id, user_id, message, is_deleted FROM room_messages WHERE id = ? AND room_id = ?', [replyToId, room.id]);
++ if (!replyTo || replyTo.is_deleted) fail(422, 'rooms.reply', 'That message is not here to reply to');
++ }
const filtered = wordFilter.check(body);
const message = filtered.safe ? body : filtered.filtered;
const attached = messageFiles.cleanFiles(files, user.subject_id || null);
@@@ -304,7 +311,7 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
// broadcast when it is ready; a slower one fills the row later.
const { card, pending } = await linkCards.forSend(message);
const id = await db.tx(async () => {
-- const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, card, files) VALUES (?, ?, ?, ?, ?, ?::jsonb)', [room.id, user.id, user.subject_id || null, message, linkCards.toJson(card), JSON.stringify(attached)]);
++ const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, card, reply_to_id, files) VALUES (?, ?, ?, ?, ?, ?, ?::jsonb)', [room.id, user.id, user.subject_id || null, message, linkCards.toJson(card), replyToId, JSON.stringify(attached)]);
await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
await db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
@@@ -312,6 +319,11 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
});
const row = await messageById(id);
if (pending) linkCards.fillLater('room_messages', id, pending);
++ // Activity: the people this names, and the person it answers (server/chat/activity.js). A failure here never
++ // takes a message back.
++ const activity = require('../chat/activity');
++ await activity.mentionsInRoom({ room, message: row, actor: user });
++ if (replyTo) await activity.replyInRoom({ room, message: row, parent: replyTo, actor: user });
// The chat plays commands of an agent stream's room (Agent Streams S6): '!do', '!vote', and the
// moderators' '!skip', '!remove' and '!pin'. They are handled after the message is out, so a request is
// a message of the room and a thumbs up on it is a vote.
@@@ -342,7 +354,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
}
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
--const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.created_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,
++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE u.display_name END AS display_name,
m.agent_stream, u.profile_color, u.role AS user_role
@@@ -421,6 +433,9 @@ async function setRole(room, actor, targetUserId, role) {
: role === 'speaker' && current !== 'speaker' ? 'room_speaker_add'
: current === 'speaker' && role !== 'speaker' ? 'room_speaker_remove' : null;
if (action) await db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: action, details: { from: current, to: role } });
++ if (role !== 'blocked' && role !== 'none' && current !== role && Number(targetUserId) !== Number(actor.id)) {
++ await require('../chat/activity').roleGiven({ targetUserId, actor, role, roomId: room.id });
++ }
return role;
}
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex fae381c..da0198b 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -39,6 +39,8 @@ const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const parties = require('./parties');
const ctx = require('../live-context');
++const db = require('../db/database');
++const activity = require('../chat/activity');
const router = express.Router();
@@@ -53,6 +55,8 @@ const deleteLimit = limits('chat.room.message.delete', { minute: 60, hour: 600 }
const manageLimit = limits('chat.room.manage', { minute: 30, hour: 300 });
// Marking read: once per room opened or caught up.
const readLimit = limits('chat.room.read_mark', { minute: 60, hour: 1200 });
++// One reaction at a time, changed or taken back: a person taps a few a minute.
++const reactLimit = limits('chat.room.reaction', { minute: 120, hour: 1200 });
// Attaching links the room to a Community space: an owner sets up a few.
const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });
@@@ -205,12 +209,41 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
try {
-- const message = await rooms.post(room, req.user, req.body && req.body.message, { timezone: req.body && req.body.timezone, files: req.body && req.body.files });
++ const message = await rooms.post(room, req.user, req.body && req.body.message, {
++ timezone: req.body && req.body.timezone,
++ files: req.body && req.body.files,
++ reply_to_id: req.body && req.body.reply_to_id,
++ });
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
res.status(201).json({ message });
} catch (err) { send(res, err); }
});
++// A reaction on a room message (room_message_reactions, migration 0038): the same one again takes it back. The
++// author's activity gets one row per person and message (server/chat/activity.js reaction).
++router.post('/:slug/messages/:id/react', requireAuth, reactLimit, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ const reaction = String((req.body && req.body.reaction) || '');
++ if (!activity.REACTIONS.includes(reaction)) return res.status(422).json({ error: 'Unknown reaction', code: 'rooms.reaction' });
++ const message = await db.get('SELECT id, user_id, message, is_deleted FROM room_messages WHERE id = ? AND room_id = ?', [Number(req.params.id) || 0, room.id]);
++ if (!message || message.is_deleted) return res.status(404).json({ error: 'No such message', code: 'rooms.no_message' });
++ try {
++ const access = await rooms.access(room, req.user);
++ if (!access.read || !access.role) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
++ const existing = await db.get('SELECT reaction FROM room_message_reactions WHERE message_id = ? AND user_id = ?', [message.id, req.user.id]);
++ if (existing && existing.reaction === reaction) {
++ await db.run('DELETE FROM room_message_reactions WHERE message_id = ? AND user_id = ?', [message.id, req.user.id]);
++ await activity.removeReaction({ message, actor: req.user });
++ return res.json({ ok: true, reaction: null });
++ }
++ await db.run(`INSERT INTO room_message_reactions (message_id, user_id, reaction) VALUES (?, ?, ?)
++ ON CONFLICT (message_id, user_id) DO UPDATE SET reaction = EXCLUDED.reaction, created_at = now()`,
++ [message.id, req.user.id, reaction]);
++ await activity.reaction({ room, message, actor: req.user, reaction });
++ res.json({ ok: true, reaction });
++ } catch (err) { send(res, err); }
++});
++
// A picture (server/rooms/pictures.js): the body is the picture (Content-Type image/*), an optional caption in the
// X-Caption header (URI-encoded). It goes to OpenVibe.Pics as the person, and its link is posted as a message.
const pictureLimit = limits('chat.room.picture', { minute: 10, hour: 120 });
ddiff --git a/server/rooms/server-routes.js b/server/rooms/server-routes.js
iindex b3f6c51..534a63f 100644
---- a/server/rooms/server-routes.js
++++ b/server/rooms/server-routes.js
@@@ -307,7 +307,17 @@ router.get('/:slug/invites', requireAuth, async (req, res) => {
router.post('/:slug/invites', requireAuth, manageLimit, async (req, res) => {
const server = await readable(req, res); if (!server) return;
try {
-- const invite = await servers.createInvite(server, req.user, req.body || {});
++ const b = req.body || {};
++ // One named person gets a single-use invite and an activity row; a plain link is the same as before.
++ const targetId = Number(b.user_id) || 0;
++ const name = String(b.username || '').trim();
++ if (targetId || /^[A-Za-z0-9_]{3,24}$/.test(name)) {
++ const target = targetId ? await ctx.getUserById(targetId) : await ctx.getUserByUsername(name) || await ctx.ensureUserByUsername(name).catch(() => null);
++ if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'servers.no_user' });
++ const invite = await servers.invitePerson(server, req.user, target.id);
++ return res.status(201).json({ invite, user_id: target.id, url: `${siteUrl()}/join/${invite.code}` });
++ }
++ const invite = await servers.createInvite(server, req.user, b);
res.status(201).json({ invite, url: `${siteUrl()}/join/${invite.code}` });
} catch (err) { send(res, err); }
});
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex f010dc2..ab64606 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -330,6 +330,9 @@ async function setRole(server, actor, targetUserId, role) {
: role === 'none' && current ? 'server_kick'
: role !== current ? `server_role_${role}` : null;
if (action) await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: target.id, action_type: action, details: { from: current, to: role } });
++ if (current !== role && current !== 'blocked' && role !== 'blocked' && role !== 'none' && target.id !== actor.id) {
++ await require('../chat/activity').roleGiven({ targetUserId: target.id, actor, role, serverId: server.id });
++ }
return role === 'none' ? null : role;
}
@@@ -475,6 +478,24 @@ async function createInvite(server, actor, { max_uses = null, expires_hours = nu
[code, server.id, actor.id, actor.subject_id || null, uses, expires]);
return publicInvite(await db.get('SELECT * FROM chat_server_invites WHERE code = ?', [code]));
}
++/**
++ * Invite one person (not just a link anyone may use): a single-use invite, and an activity row for them so the
++ * invite is waiting in their Activity. Managers, and mods in a public server, as createInvite; the person must not
++ * already be in the server.
++ */
++async function invitePerson(server, actor, targetUserId) {
++ const a = await access(server, actor);
++ if (!(a.manage || (a.moderate && server.visibility === 'public'))) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins invite people');
++ const target = await ctx.getUserById(Number(targetUserId));
++ if (!target) fail(404, 'servers.no_user', 'No such person');
++ const current = await roleIn(server.id, target.id);
++ if (current === 'blocked') fail(403, 'servers.blocked', 'They are banned from this server');
++ if (current) fail(409, 'servers.already_member', 'They are already in this server');
++ const invite = await createInvite(server, actor, { max_uses: 1, expires_hours: 168 });
++ await require('../chat/activity').serverInvite({ userId: target.id, actor, server, code: invite.code });
++ return invite;
++}
++
async function invites(server) {
return (await db.all('SELECT * FROM chat_server_invites WHERE server_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 100', [server.id])).map(publicInvite);
}
@@@ -501,7 +522,7 @@ async function archive(server, actor) {
module.exports = {
bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,
create, list, join, leave, setRole, members, update, createChannel, updateChannel, deleteChannel,
-- createInvite, invites, revokeInvite, inviteByCode, archive,
++ createInvite, invitePerson, invites, revokeInvite, inviteByCode, archive,
selfRolesOf, hasOnboarding, memberRoles, setMemberRoles,
featured, setFeatured,
SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS, OFFICIAL_SLUG, MAX_RULES, MAX_SELF_ROLES,
ddiff --git a/server/web/activity.js b/server/web/activity.js
nnew file mode 100644
iindex 0000000..00f585a
---- /dev/null
++++ b/server/web/activity.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * "What happened" on openvibe.chat/activity: the person's activity feed (server/chat/activity.js) in the same plain
++ * list markup the page's other sections use — who, what, where and when, each row opening the place. The page is
++ * designed elsewhere; this only supplies the one section, and its look is the shared list's.
++ *
++ * Opening the page counts as reading it: the rows are taken first (so the newest ones still read as unread on this
++ * visit), then the rows shown are marked read and the bottom bar's dot goes away on the next page.
++ */
++const { esc } = require('./layout');
++const ui = require('./ui');
++const activity = require('../chat/activity');
++
++const LIMIT = 15;
++const REACTION = { thumbsup: '👍', thumbsdown: '👎', heart: '❤️', laugh: '😄', surprised: '😮', sad: '😢' };
++const ROLE = { owner: 'owner', admin: 'admin', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer' };
++
++function excerpt(text) {
++ const s = String(text || '').replace(/\s+/g, ' ').trim();
++ if (!s) return '';
++ const cut = s.length > 90 ? `${s.slice(0, 89)}…` : s;
++ return ` <span class="oc-muted">“${esc(cut)}”</span>`;
++}
++
++/** One row's "what happened", without the actor and place (both are drawn by the row). */
++function what(row, place) {
++ const where = `<span class="oc-muted">${esc(place.label)}</span>`;
++ switch (row.kind) {
++ case 'mention': return `mentioned you in ${where}${excerpt(row.what.excerpt)}`;
++ case 'reply': return `replied to your message in ${where}${excerpt(row.what.excerpt)}`;
++ case 'reaction': return `reacted ${REACTION[row.what.reaction] || esc(row.what.reaction || '')} to your message in ${where}`;
++ case 'server_invite': return `invited you to ${where}`;
++ case 'friend_accept': return 'accepted your friend request';
++ case 'role': return `gave you the ${esc(ROLE[row.what.role] || row.what.role || '')} role in ${where}`;
++ default: return `did something in ${where}`;
++ }
++}
++
++/**
++ * The section, or '' when there is nothing to show. `user` is the signed-in person.
++ * → HTML for one <section> in the page's plain list markup.
++ */
++async function whatHappened(user) {
++ if (!user || !user.id) return '';
++ const { items } = await activity.list(user, { limit: LIMIT });
++ if (!items.length) return '';
++ // Seen now: the rows above keep their unread mark for this visit. Only what this page showed is read; a
++ // longer feed keeps the rest unread for the next visit.
++ await activity.markRead(user, { ids: items.map((row) => row.id) }).catch(() => {});
++ const rows = items.map((row) => {
++ const who = row.actor.display_name || row.actor.username || 'Someone';
++ const at = ui.when(row.at);
++ return `<li class="oc-row oc-act-row${row.read ? '' : ' oc-unread'}"><a href="${esc(row.where.href)}">
++${ui.initials(who, null, { avatarUrl: row.actor.avatar_url })}
++<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(who)}</strong>${at.text ? `<time datetime="${esc(at.iso)}">${esc(at.text)}</time>` : ''}</span>
++<span class="oc-row-sub">${what(row, row.where)}</span></span></a></li>`;
++ }).join('\n');
++ return `<section class="oc-act-sec" aria-labelledby="oc-act-what">
++<h2 class="oc-list-head" id="oc-act-what">What happened</h2>
++<ul class="oc-list">${rows}</ul>
++</section>`;
++}
++
++module.exports = { whatHappened };
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 4061509..0ff8fd6 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -58,6 +58,7 @@ function links(o) {
}
function navConfig(o, config, site = {}, siteLinks = null) {
++ const counts = o.counts || {};
return {
service: site.service || 'chat',
apiBase: NETWORK_URL,
@@@ -65,7 +66,8 @@ function navConfig(o, config, site = {}, siteLinks = null) {
history: { type: 'page', title: o.title || site.name || SITE_NAME },
// The phone's bottom bar (Shared's app bar, appbar.js): Home, Servers, Vibe, Activity and You — one
// bar for every OpenVibe app (the plan, 2026-11-09, stage V2).
-- appBar: require('./shell').APP_BAR.map((l) => ({ ...l, active: o.active === l.page })),
++ // The Activity place carries a dot while something in it is unread (Shared's appbar.js draws `dot`).
++ appBar: require('./shell').APP_BAR.map(({ dot, ...l }) => ({ ...l, ...(dot ? { dot: !!counts.activity } : {}), active: o.active === l.page })),
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 61a24b4..ecf7f9d 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -184,6 +184,7 @@ function createWebRoutes({ config }) {
messages: await n(async () => await dm.getTotalUnread(actor.user.id)),
rooms: await n(async () => await rooms.unreadTotal(actor.user)),
servers: await n(async () => await rooms.unreadTotal(actor.user, { servers: true })),
++ activity: await n(async () => await require('../chat/activity').unreadCount(actor.user)),
};
};
// Under the global room on the front page: what openvibe.chat is for (openvibe-shared/showcase), each card a real
@@@ -211,7 +212,7 @@ function createWebRoutes({ config }) {
res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).set('Content-Security-Policy', contentSecurityPolicy(nonce)).type('html')
.send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), nonce, ringScript: actor.kind === 'user' && !!(config.calls && config.calls.enabled), ...o }));
};
-- const needUser = async (req, res) => {
++ const needUser = async (req, res, { status = 401 } = {}) => {
const actor = await viewerOf(req);
if (actor.kind === 'user') return actor;
// A gated page answers here, noindex, instead of redirecting to Network sign-in: a crawler that
@@@ -225,7 +226,7 @@ function createWebRoutes({ config }) {
// The visible link goes back to the path only. A query string on a private page holds words that must
// never come back in an anonymous response — the security suite reads every 401 body for the private
// room's words, and echoing ?q= here put them there (SEO-7 review, 2026-10-29).
-- await page(req, res, 401, {
++ await page(req, res, status, {
actor, title: 'Sign in to continue', path: req.path, active: null, footer: 'compact', robots: 'noindex, nofollow',
body: `<h1>Sign in to continue</h1>
<p class="oc-muted">This page is for OpenVibe accounts. Sign in and you come straight back here.</p>
@@@ -376,7 +377,8 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
// friend, conversations with something new, and the channels and rooms with unread messages, each a row that opens
// the place. Nothing waiting is said as that, with the two places most people go next. ──
router.get('/activity', async (req, res) => {
-- const actor = await needUser(req, res); if (!actor) return;
++ // 200 signed out too: Activity is a page people can be sent to, with the way to sign in in it.
++ const actor = await needUser(req, res, { status: 200 }); if (!actor) return;
const me = actor.user;
const serversApi = require('../rooms/servers');
const convs = await dm.getConversations(me.id);
@@@ -408,6 +410,9 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
const roomRows = myRooms.map((r) => `<li><a class="oc-chan is-unread" href="/r/${esc(r.slug)}"><span class="oc-chan-hash" aria-hidden="true">#</span><span class="oc-chan-name">${esc(r.name)}</span><span class="oc-chan-dot" aria-label="${r.unread} unread">${r.unread > 99 ? '99+' : r.unread}</span></a></li>`);
const section = (id, title, n, inner) => (n ? `<section class="oc-act-sec" aria-labelledby="${id}"><h2 class="oc-list-head" id="${id}">${title} <span class="oc-muted">${n}</span></h2>${inner}</section>` : '');
const waiting = requests.length + convRows.length + busy.length + roomRows.length;
++ // What happened to this person, above what is waiting for them (server/web/activity.js). Opening the page
++ // reads it: the rows below still show what was new on this visit.
++ const happened = await require('./activity').whatHappened(me);
const body = waiting
? [
section('oc-act-requests', 'Friend requests', requests.length, `<ul class="oc-list">${requestRows.join('')}</ul>`),
@@@ -422,7 +427,7 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
rail: shell.rail({ dmUnread: unread, servers: shell.railServers(mineServers), list: 'Chats' }),
side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
sideLabel: 'Home',
-- main: `${ui.head({ title: 'Activity', sub: waiting ? 'What is waiting for you' : 'Nothing is waiting for you' })}\n${body}`,
++ main: `${ui.head({ title: 'Activity', sub: waiting ? 'What is waiting for you' : 'Nothing is waiting for you' })}\n${happened}\n${body}`,
mainLabel: 'Activity',
page: true,
}),
ddiff --git a/server/web/shell.js b/server/web/shell.js
iindex 19d7956..945ef9a 100644
---- a/server/web/shell.js
++++ b/server/web/shell.js
@@@ -30,7 +30,7 @@ const APP_BAR = [
{ label: 'Home', href: '/messages', icon: 'fa-comment-dots', page: 'messages' },
{ label: 'Servers', href: '/servers', icon: 'fa-people-group', page: 'servers' }, // not the globe: that is Global chat
{ label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles', page: 'vibe' },
-- { label: 'Activity', href: '/activity', icon: 'fa-bell', page: 'activity' },
++ { label: 'Activity', href: '/activity', icon: 'fa-bell', page: 'activity', dot: true },
{ label: 'You', href: '/settings', icon: 'fa-user', page: 'settings' },
];
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex e1c3342..5e5658e 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -36,7 +36,7 @@ t('export, then erase and confirm', async () => {
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
'chat_stage_speakers',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'emotes', 'inbox_actions',
-- 'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions',
++ 'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions', 'chat_activity',
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Chat applied',
ddiff --git a/test/activity.test.js b/test/activity.test.js
nnew file mode 100644
iindex 0000000..4cfb419
---- /dev/null
++++ b/test/activity.test.js
@@@ -0,0 +1,215 @@
++'use strict';
++/**
++ * Activity (migration 0061_chat_activity.sql, server/chat/activity.js): what happened to a person in Chat. A mention
++ * and a reply in a room, a reaction on a message, a server invite, an accepted friend request and a role given are
++ * one row each with who, what, where and when; the API is newest first with a cursor and a kind filter, has an
++ * unread count, and marks one or all read. A person only sees what they may still read (a room they were removed
++ * from, a deleted message) and never a blocked person's activity. The bottom bar's Activity place carries a dot
++ * while something is unread.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('activity');
++let h, ann, bob, cat;
++const api = (method, path, u, body) => h.http(method, path, { token: u && u.token, body });
++const room = (method, path, u, body) => api(method, `/api/chat/rooms${path}`, u, body);
++const activity = (method, path, u, body) => api(method, `/api/v1/activity${path}`, u, body);
++const web = async (path, u) => {
++ const r = await fetch(`${h.base}${path}`, { headers: u ? { cookie: `ov_token=${u.token}` } : {} });
++ return { status: r.status, text: await r.text() };
++};
++
++let feed, roomSlug, serverSlug, bobMessage, annMessage;
++
++t('boot and the places', async () => {
++ h = await boot();
++ // The routes this test drives over HTTP, named here too so the changed-file check sees them.
++ require('../server/chat/activity-routes');
++ require('../server/chat/contacts');
++ require('../server/chat/dm-routes');
++ require('../server/rooms/routes');
++ require('../server/rooms/server-routes');
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ cat = h.addUser('cat', { subject: ids.newId('user') });
++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
++});
++
++t('the API is signed in only, and empty at first', async () => {
++ assert.strictEqual((await activity('GET', '/', null)).status, 401);
++ assert.strictEqual((await activity('GET', '/unread', null)).status, 401);
++ const r = await activity('GET', '/', ann);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.items, r.body.unread, r.body.next_before], [[], 0, null]);
++});
++
++t('a mention and a reply in a room, and a reaction on the message', async () => {
++ let r = await room('POST', '/', ann, { name: 'Activity Lab' });
++ assert.strictEqual(r.status, 201, r.text);
++ roomSlug = r.body.room.slug;
++ assert.strictEqual((await room('POST', `/${roomSlug}/join`, bob)).status, 200);
++
++ // bob names ann and ann answers bob: one mention for ann, one reply for bob.
++ r = await room('POST', `/${roomSlug}/messages`, bob, { message: 'morning @ann, are you around?' });
++ assert.strictEqual(r.status, 201, r.text);
++ bobMessage = r.body.message.id;
++ r = await room('POST', `/${roomSlug}/messages`, ann, { message: 'here now', reply_to_id: bobMessage });
++ assert.strictEqual(r.status, 201, r.text);
++ annMessage = r.body.message.id;
++ assert.strictEqual(r.body.message.reply_to_id, bobMessage, 'the reply keeps the message it answers');
++
++ // A reaction on bob's message: ann's heart is bob's activity.
++ r = await room('POST', `/${roomSlug}/messages/${bobMessage}/react`, ann, { reaction: 'heart' });
++ assert.deepStrictEqual([r.status, r.body.reaction], [200, 'heart']);
++ // The same reaction again takes it back, and its activity goes with it.
++ r = await room('POST', `/${roomSlug}/messages/${bobMessage}/react`, ann, { reaction: 'thumbsup' });
++ assert.strictEqual(r.body.reaction, 'thumbsup', 'changing a reaction updates it');
++
++ let mine = await activity('GET', '/', ann);
++ assert.deepStrictEqual(mine.body.items.map((x) => x.kind), ['mention'], mine.text);
++ assert.strictEqual(mine.body.items[0].actor.username, 'bob');
++ assert.strictEqual(mine.body.items[0].where.label, '#Activity Lab');
++ assert.match(mine.body.items[0].where.href, new RegExp(`^/r/${roomSlug}$`));
++ assert.strictEqual(mine.body.items[0].read, false);
++ assert.strictEqual(mine.body.unread, 1);
++
++ mine = await activity('GET', '/', bob);
++ assert.deepStrictEqual(mine.body.items.map((x) => x.kind).sort(), ['reaction', 'reply']);
++ // Newest first: the reaction came after the reply.
++ assert.deepStrictEqual(mine.body.items.map((x) => x.kind), ['reaction', 'reply']);
++ assert.strictEqual(mine.body.items[0].what.reaction, 'thumbsup');
++ assert.ok(mine.body.items[0].at, 'every row says when');
++});
++
++t('a server invite and a role given', async () => {
++ let r = await api('POST', '/api/chat/servers', ann, { name: 'Activity Server' });
++ assert.strictEqual(r.status, 201, r.text);
++ serverSlug = r.body.server.slug;
++ r = await api('POST', `/api/chat/servers/${serverSlug}/invites`, ann, { username: 'cat' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.body.invite.max_uses, 1, 'a named invite is for one person');
++ let mine = await activity('GET', '/', cat);
++ assert.deepStrictEqual(mine.body.items.map((x) => x.kind), ['server_invite']);
++ assert.strictEqual(mine.body.items[0].actor.username, 'ann');
++ assert.match(mine.body.items[0].where.href, /^\/join\//);
++
++ // The server's owner gives cat a role: that is cat's activity too.
++ r = await api('POST', `/api/chat/servers/${serverSlug}/members`, ann, { username: 'cat', role: 'mod' });
++ assert.strictEqual(r.status, 200, r.text);
++ mine = await activity('GET', '/', cat);
++ assert.deepStrictEqual(mine.body.items.map((x) => x.kind), ['role', 'server_invite']);
++ assert.strictEqual(mine.body.items[0].what.role, 'mod');
++ assert.strictEqual(mine.body.items[0].where.kind, 'server');
++});
++
++t('an accepted friend request', async () => {
++ // ann asks bob; bob accepts: ann hears that it was accepted.
++ let r = await api('POST', '/api/dm/contacts', ann, { username: 'bob' });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await api('POST', `/api/dm/contacts/${ann.id}/accept`, bob, {});
++ assert.strictEqual(r.status, 200, r.text);
++ const mine = await activity('GET', '/', ann);
++ assert.ok(mine.body.items.some((x) => x.kind === 'friend_accept' && x.actor.username === 'bob'), mine.text);
++ assert.strictEqual(mine.body.items.find((x) => x.kind === 'friend_accept').where.href, '/messages');
++});
++
++t('newest first, a cursor and a kind filter', async () => {
++ const all = (await activity('GET', '/?limit=100', ann)).body.items;
++ assert.ok(all.length >= 2);
++ const first = (await activity('GET', '/?limit=1', ann)).body;
++ assert.strictEqual(first.items.length, 1);
++ assert.strictEqual(first.next_before, first.items[0].id);
++ const second = (await activity('GET', `/?limit=50&before=${first.next_before}`, ann)).body;
++ assert.ok(second.items.length >= 1);
++ assert.ok(second.items[0].id < first.items[0].id, 'the cursor is exclusive');
++ assert.deepStrictEqual((await activity('GET', '/?kind=mention', ann)).body.items.map((x) => x.kind), ['mention']);
++ const bad = await activity('GET', '/?kind=nope', ann);
++ assert.deepStrictEqual([bad.status, bad.body.code], [422, 'activity.kind']);
++});
++
++t('one read, then all: the unread count follows', async () => {
++ const before = (await activity('GET', '/unread', ann)).body.unread;
++ assert.ok(before >= 2);
++ const first = (await activity('GET', '/?limit=1', ann)).body.items[0];
++ let r = await activity('POST', `/${first.id}/read`, ann, {});
++ assert.deepStrictEqual([r.status, r.body.read, r.body.unread], [200, 1, before - 1]);
++ r = await activity('POST', `/${first.id}/read`, ann, {});
++ assert.deepStrictEqual([r.body.read, r.body.unread], [0, before - 1], 'reading it again is harmless');
++ r = await activity('POST', '/read', ann, { all: 1 });
++ assert.deepStrictEqual([r.body.ok, r.body.unread], [true, 0]);
++ assert.strictEqual((await activity('GET', '/', ann)).body.items.every((x) => x.read), true);
++});
++
++t('a room the person was removed from, and a blocked person, are not shown', async () => {
++ // A private room of bob's; ann is let in, named, then removed.
++ let r = await room('POST', '/', bob, { name: 'Private Lab', visibility: 'private' });
++ assert.strictEqual(r.status, 201);
++ const priv = r.body.room.slug;
++ r = await room('POST', `/${priv}/members`, bob, { username: 'ann', role: 'member' });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await room('POST', `/${priv}/messages`, bob, { message: 'secret @ann' });
++ assert.strictEqual(r.status, 201);
++ let mine = (await activity('GET', '/', ann)).body;
++ assert.ok(mine.items.some((x) => x.where.room === priv), 'the mention is in the feed while ann may read it');
++ r = await room('POST', `/${priv}/members`, bob, { username: 'ann', role: 'none' });
++ assert.strictEqual(r.status, 200, r.text);
++ mine = (await activity('GET', '/', ann)).body;
++ assert.ok(!mine.items.some((x) => x.where.room === priv), 'no mention from a room ann can no longer read');
++
++ // A blocked person's activity is left out (Chat's own block table).
++ r = await api('POST', `/api/dm/blocks/${bob.id}`, ann, {});
++ assert.strictEqual(r.status, 200, r.text);
++ mine = (await activity('GET', '/', ann)).body;
++ assert.ok(!mine.items.some((x) => x.actor.username === 'bob'), 'a blocked person is left out');
++ r = await api('POST', '/api/v1/activity/read', ann, { all: true });
++ assert.strictEqual(r.body.unread, 0);
++});
++
++t('the bottom bar Activity place carries a dot while something is unread', async () => {
++ // A fresh mention for ann, then the signed-in app page carries the dot on Activity.
++ await api('DELETE', `/api/dm/blocks/${bob.id}`, ann, {});
++ await room('POST', `/${roomSlug}/messages`, bob, { message: 'one more @ann' });
++ let page = await web('/messages', ann);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /"label":"Activity"[^}]*"dot":true/, page.text.slice(0, 200));
++ await api('POST', '/api/v1/activity/read', ann, { all: true });
++ page = await web('/messages', ann);
++ assert.match(page.text, /"label":"Activity"[^}]*"dot":false/);
++});
++
++t('a deleted message leaves the feed, and the page section renders who, what, where and when', async () => {
++ // One more mention of ann, then the message is deleted by its author: the row goes with it.
++ let r = await room('POST', `/${roomSlug}/messages`, bob, { message: 'delete this @ann' });
++ assert.strictEqual(r.status, 201, r.text);
++ const messageId = r.body.message.id;
++ assert.ok((await activity('GET', '/?kind=mention', ann)).body.items.some((x) => x.what.excerpt === 'delete this @ann'));
++ r = await room('DELETE', `/${roomSlug}/messages/${messageId}`, bob);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(!(await activity('GET', '/?kind=mention', ann)).body.items.some((x) => x.what.excerpt === 'delete this @ann'), 'a deleted message is not shown');
++
++ // The page's section: who, what, where and when, in the shared plain list markup.
++ await room('POST', `/${roomSlug}/messages`, bob, { message: 'render me @ann' });
++ const html = await require('../server/web/activity').whatHappened(ann);
++ assert.match(html, /What happened/);
++ assert.match(html, /bob/);
++ assert.match(html, /mentioned you/);
++ assert.match(html, /#Activity Lab/);
++ assert.match(html, /<time datetime=/);
++ assert.strictEqual((await activity('GET', '/unread', ann)).body.unread, 0, 'opening the page reads it');
++
++ // The page itself: the section sits above the page's own sections.
++ await room('POST', `/${roomSlug}/messages`, bob, { message: 'page me @ann' });
++ let page = await web('/activity', ann);
++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
++ assert.match(page.text, /<h1[^>]*>Activity<\/h1>/);
++ assert.match(page.text, /What happened/);
++ assert.match(page.text, /page me @ann|page me/);
++ assert.ok(page.text.indexOf('What happened') < page.text.indexOf('Friend requests') || !page.text.includes('Friend requests'), 'the feed is above the waiting sections');
++ assert.strictEqual((await activity('GET', '/unread', ann)).body.unread, 0, 'opening /activity reads it');
++ const out = await web('/activity', null);
++ assert.strictEqual(out.status, 200, 'the page answers 200 signed out, with the way to sign in');
++ assert.match(out.text, /Sign in/);
++ assert.ok(!/What happened/.test(out.text), 'and nothing of anyone');
++});
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex ac30015..4e48626 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -34,7 +34,7 @@ const TABLES = [
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties', 'chat_stage_sessions', 'chat_stage_speakers',
'room_sync_clocks', 'room_control_leases',
-- 'room_message_reactions', 'room_member_timeouts', 'config_snapshots', 'config_keys',
++ 'room_message_reactions', 'room_member_timeouts', 'config_snapshots', 'config_keys', 'chat_activity',
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
];
const INDEXES = [