openvibers/openvibe.chat Commits
Activity: what happened to you (mentions, replies, reactions, invites, friends, roles) (#468)
Changes
nnew file mode 100644@@@ -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;@@@ -335,6 +335,9 @@ function createApp({ chatServer, relay, events = null, callServer = null, settinapp.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/…@@@ -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)'],nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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,++};@@@ -1201,6 +1201,14 @@ class ChatServer {// Attach reply context to broadcastif (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.@@@ -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;}@@@ -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@@@ -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 charsconst 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_atawait 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;}@@@ -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;}@@@ -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 });@@@ -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); }});@@@ -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,nnew file mode 100644@@@ -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 };@@@ -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 || '/')}`,@@@ -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>', htmlconst 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>', htmlrail: 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,}),@@@ -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' },];@@@ -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',nnew file mode 100644@@@ -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');++});@@@ -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 = [
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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;
@@ -335,6 +335,9 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -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/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/') }));
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).
// 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);
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/…
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
@@ -95,6 +95,8 @@ const EXPORTS = [
@@ -95,6 +95,8 @@ const EXPORTS = [
// Co-pilot rooms (S5): the polls they voted in, and their reactions to room messages.
// 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}`],
['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}`],
['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) {
async function exportPart(d, subject) {
@@ -144,6 +146,7 @@ const ERASE_AFTER_ROOMS = [
@@ -144,6 +146,7 @@ const ERASE_AFTER_ROOMS = [
['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],
['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)'],
['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)'],
['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)'],
['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
// Friends and conversation view preferences from the Chat app shell.
// 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)'],
['dm_contacts', 'DELETE FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids)'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,392 @@
@@ -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,
+};
@@ -1201,6 +1201,14 @@ class ChatServer {
@@ -1201,6 +1201,14 @@ class ChatServer {
// Attach reply context to broadcast
// Attach reply context to broadcast
if (replyTo) chatMsg.reply_to = replyTo;
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
// 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) —
// 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.
// are ONE call to Live per message; the coin result comes back for this socket.
@@ -67,6 +67,12 @@ async function accept(userId, contactId) {
@@ -67,6 +67,12 @@ async function accept(userId, contactId) {
if (!mine || mine.status !== 'incoming') return false;
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 = ?", [a, b]);
await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [b, a]);
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;
return true;
}
}
@@ -256,7 +256,7 @@ router.post('/conversations/:id/messages', sendLimit, async (req, res) => {
@@ -256,7 +256,7 @@ router.post('/conversations/:id/messages', sendLimit, async (req, res) => {
return res.status(400).json({ error: 'Message required' });
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' });
if (!msg) return res.status(400).json({ error: 'Failed to send' });
// Attach sender info
// Attach sender info
@@ -162,16 +162,22 @@ async function getConversation(conversationId) {
@@ -162,16 +162,22 @@ async function getConversation(conversationId) {
/**
/**
* Send a message in a conversation. Returns the message row.
* 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;
if (!text || !text.trim()) return null;
const trimmed = text.trim().slice(0, 2000); // max 2000 chars
const trimmed = text.trim().slice(0, 2000); // max 2000 chars
const senderSubject = await db.subjectFor(senderId);
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).
// 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 { card, pending } = await linkCards.forSend(trimmed);
const row = await db.tx(async () => {
const row = await db.tx(async () => {
const result = await db.run(
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
// Touch conversation updated_at
await db.run(`UPDATE dm_conversations SET updated_at = ov_now() WHERE id = ?`, [conversationId]);
await db.run(`UPDATE dm_conversations SET updated_at = ov_now() WHERE id = ?`, [conversationId]);
@@ -197,6 +203,13 @@ async function sendMessage(conversationId, senderId, text) {
@@ -197,6 +203,13 @@ async function sendMessage(conversationId, senderId, text) {
if (row) {
if (row) {
row.card = card;
row.card = card;
if (pending) linkCards.fillLater('dm_messages', row.id, pending);
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;
return row;
}
}
@@ -268,7 +268,7 @@ async function announceDeleted(room, ids) {
@@ -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);
const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from 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 =
@@ -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) });
if (open) await polls.castVote({ poll: open, user, optionId: body.slice(1) });
} catch { /* a vote that does not count is never a failed message */ }
} 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 filtered = wordFilter.check(body);
const message = filtered.safe ? body : filtered.filtered;
const message = filtered.safe ? body : filtered.filtered;
const attached = messageFiles.cleanFiles(files, user.subject_id || null);
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 =
@@ -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.
// broadcast when it is ready; a slower one fills the row later.
const { card, pending } = await linkCards.forSend(message);
const { card, pending } = await linkCards.forSend(message);
const id = await db.tx(async () => {
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 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 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);
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 =
@@ -312,6 +319,11 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
});
});
const row = await messageById(id);
const row = await messageById(id);
if (pending) linkCards.fillLater('room_messages', id, pending);
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
// 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
// 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.
// 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 } = {
@@ -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.
// 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.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,
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
m.agent_stream, u.profile_color, u.role AS user_role
@@ -421,6 +433,9 @@ async function setRole(room, actor, targetUserId, role) {
@@ -421,6 +433,9 @@ async function setRole(room, actor, targetUserId, role) {
: role === 'speaker' && current !== 'speaker' ? 'room_speaker_add'
: role === 'speaker' && current !== 'speaker' ? 'room_speaker_add'
: current === 'speaker' && role !== 'speaker' ? 'room_speaker_remove' : null;
: 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 (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;
return role;
}
}
@@ -39,6 +39,8 @@ const { limits } = require('../net/actor-limits');
@@ -39,6 +39,8 @@ const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const rooms = require('./rooms');
const parties = require('./parties');
const parties = require('./parties');
const ctx = require('../live-context');
const ctx = require('../live-context');
+const db = require('../db/database');
+const activity = require('../chat/activity');
const router = express.Router();
const router = express.Router();
@@ -53,6 +55,8 @@ const deleteLimit = limits('chat.room.message.delete', { minute: 60, hour: 600 }
@@ -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 });
const manageLimit = limits('chat.room.manage', { minute: 30, hour: 300 });
// Marking read: once per room opened or caught up.
// Marking read: once per room opened or caught up.
const readLimit = limits('chat.room.read_mark', { minute: 60, hour: 1200 });
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.
// Attaching links the room to a Community space: an owner sets up a few.
const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });
const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });
@@ -205,12 +209,41 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
@@ -205,12 +209,41 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
try {
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 });
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
res.status(201).json({ message });
res.status(201).json({ message });
} catch (err) { send(res, err); }
} 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
// 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.
// 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 });
const pictureLimit = limits('chat.room.picture', { minute: 10, hour: 120 });
@@ -307,7 +307,17 @@ router.get('/:slug/invites', requireAuth, async (req, res) => {
@@ -307,7 +307,17 @@ router.get('/:slug/invites', requireAuth, async (req, res) => {
router.post('/:slug/invites', requireAuth, manageLimit, async (req, res) => {
router.post('/:slug/invites', requireAuth, manageLimit, async (req, res) => {
const server = await readable(req, res); if (!server) return;
const server = await readable(req, res); if (!server) return;
try {
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}` });
res.status(201).json({ invite, url: `${siteUrl()}/join/${invite.code}` });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
@@ -330,6 +330,9 @@ async function setRole(server, actor, targetUserId, role) {
@@ -330,6 +330,9 @@ async function setRole(server, actor, targetUserId, role) {
: role === 'none' && current ? 'server_kick'
: role === 'none' && current ? 'server_kick'
: role !== current ? `server_role_${role}` : null;
: 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 (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;
return role === 'none' ? null : role;
}
}
@@ -475,6 +478,24 @@ async function createInvite(server, actor, { max_uses = null, expires_hours = nu
@@ -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]);
[code, server.id, actor.id, actor.subject_id || null, uses, expires]);
return publicInvite(await db.get('SELECT * FROM chat_server_invites WHERE code = ?', [code]));
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) {
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);
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) {
@@ -501,7 +522,7 @@ async function archive(server, actor) {
module.exports = {
module.exports = {
bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,
bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,
create, list, join, leave, setRole, members, update, createChannel, updateChannel, deleteChannel,
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,
selfRolesOf, hasOnboarding, memberRoles, setMemberRoles,
featured, setFeatured,
featured, setFeatured,
SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS, OFFICIAL_SLUG, MAX_RULES, MAX_SELF_ROLES,
SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS, OFFICIAL_SLUG, MAX_RULES, MAX_SELF_ROLES,
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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 };
@@ -58,6 +58,7 @@ function links(o) {
@@ -58,6 +58,7 @@ function links(o) {
}
}
function navConfig(o, config, site = {}, siteLinks = null) {
function navConfig(o, config, site = {}, siteLinks = null) {
+ const counts = o.counts || {};
return {
return {
service: site.service || 'chat',
service: site.service || 'chat',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
@@ -65,7 +66,8 @@ function navConfig(o, config, site = {}, siteLinks = null) {
@@ -65,7 +66,8 @@ function navConfig(o, config, site = {}, siteLinks = null) {
history: { type: 'page', title: o.title || site.name || SITE_NAME },
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
// 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).
// 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}`,
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
@@ -184,6 +184,7 @@ function createWebRoutes({ config }) {
@@ -184,6 +184,7 @@ function createWebRoutes({ config }) {
messages: await n(async () => await dm.getTotalUnread(actor.user.id)),
messages: await n(async () => await dm.getTotalUnread(actor.user.id)),
rooms: await n(async () => await rooms.unreadTotal(actor.user)),
rooms: await n(async () => await rooms.unreadTotal(actor.user)),
servers: await n(async () => await rooms.unreadTotal(actor.user, { servers: true })),
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
// 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 }) {
@@ -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')
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 }));
.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);
const actor = await viewerOf(req);
if (actor.kind === 'user') return actor;
if (actor.kind === 'user') return actor;
// A gated page answers here, noindex, instead of redirecting to Network sign-in: a crawler that
// A gated page answers here, noindex, instead of redirecting to Network sign-in: a crawler that
@@ -225,7 +226,7 @@ function createWebRoutes({ config }) {
@@ -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
// 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
// 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).
// 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',
actor, title: 'Sign in to continue', path: req.path, active: null, footer: 'compact', robots: 'noindex, nofollow',
body: `<h1>Sign in to continue</h1>
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>
<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
@@ -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
// 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. ──
// the place. Nothing waiting is said as that, with the two places most people go next. ──
router.get('/activity', async (req, res) => {
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 me = actor.user;
const serversApi = require('../rooms/servers');
const serversApi = require('../rooms/servers');
const convs = await dm.getConversations(me.id);
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
@@ -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 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 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;
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
const body = waiting
? [
? [
section('oc-act-requests', 'Friend requests', requests.length, `<ul class="oc-list">${requestRows.join('')}</ul>`),
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
@@ -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' }),
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 || '' } }),
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',
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',
mainLabel: 'Activity',
page: true,
page: true,
}),
}),
@@ -30,7 +30,7 @@ const APP_BAR = [
@@ -30,7 +30,7 @@ const APP_BAR = [
{ label: 'Home', href: '/messages', icon: 'fa-comment-dots', page: 'messages' },
{ 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: '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: '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' },
{ label: 'You', href: '/settings', icon: 'fa-user', page: 'settings' },
];
];
@@ -36,7 +36,7 @@ t('export, then erase and confirm', async () => {
@@ -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',
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
'chat_stage_speakers',
'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',
'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'],
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Chat applied',
account_data_events: 'the ledger of requests itself: which export and deletion Chat applied',
new file mode 100644
new file mode 100644
@@ -0,0 +1,215 @@
@@ -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');
+});
@@ -34,7 +34,7 @@ const TABLES = [
@@ -34,7 +34,7 @@ const TABLES = [
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
'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',
'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_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',
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
];
];
const INDEXES = [
const INDEXES = [