Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2820a64e3 | |||
| 272c6acd31 | |||
| eb0ace8ad7 | |||
| e2f66fe622 | |||
| 9a0c74cb6e | |||
| 0c8eaaf74b | |||
| ca02da000f | |||
| 2c61f49de1 | |||
| c946f1e061 | |||
| 3d08fa42f8 | |||
| 0336621c01 | |||
| 4f9a252118 | |||
| 1cbfddd4c2 | |||
| 90e37edf89 | |||
| b8bf1aa347 | |||
| 745a23823a |
@@ -7,6 +7,11 @@ JWT_SECRET="dev-only-change-me"
|
|||||||
# Per-app HS256 secrets, JSON map keyed by appId (the `session` platform port)
|
# Per-app HS256 secrets, JSON map keyed by appId (the `session` platform port)
|
||||||
APP_SECRETS={"portal-demo":"dev-secret"}
|
APP_SECRETS={"portal-demo":"dev-secret"}
|
||||||
|
|
||||||
|
# Redis. Unset → single-instance mode: socket.io uses its in-memory adapter (realtime does NOT
|
||||||
|
# fan out across replicas) and presence is a per-process Map (so the "don't push a thread you're
|
||||||
|
# viewing" gate only sees sockets on the same replica). REQUIRED for any multi-replica deploy.
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
# Full-text message search (Meilisearch). Unset MEILI_URL → search is disabled (no-op).
|
# Full-text message search (Meilisearch). Unset MEILI_URL → search is disabled (no-op).
|
||||||
# MEILI_KEY must equal the meilisearch server's MEILI_MASTER_KEY (docker-compose default below).
|
# MEILI_KEY must equal the meilisearch server's MEILI_MASTER_KEY (docker-compose default below).
|
||||||
MEILI_URL="http://localhost:7700"
|
MEILI_URL="http://localhost:7700"
|
||||||
@@ -21,3 +26,10 @@ IIOS_CRED_KEY=""
|
|||||||
# Orphaned-media garbage collection (uploaded-but-never-sent attachments).
|
# Orphaned-media garbage collection (uploaded-but-never-sent attachments).
|
||||||
# Interval 0 = off; set e.g. 3600000 (hourly) in prod. Grace defaults to 24h.
|
# Interval 0 = off; set e.g. 3600000 (hourly) in prod. Grace defaults to 24h.
|
||||||
IIOS_MEDIA_GC_INTERVAL_MS=0
|
IIOS_MEDIA_GC_INTERVAL_MS=0
|
||||||
|
|
||||||
|
# Web Push (VAPID) for offline notifications. Unset VAPID_PUBLIC_KEY → push is disabled
|
||||||
|
# (subscribe endpoint still stores subs, but WebPushDelivery returns 'failed' — no sends).
|
||||||
|
# Generate a keypair with: npx web-push generate-vapid-keys
|
||||||
|
VAPID_PUBLIC_KEY=""
|
||||||
|
VAPID_PRIVATE_KEY=""
|
||||||
|
VAPID_SUBJECT="mailto:dev@insignia"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-kernel-client",
|
"name": "@insignia/iios-kernel-client",
|
||||||
"version": "0.1.4",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ export interface OpenThreadResult {
|
|||||||
|
|
||||||
export interface ReceiptEvent {
|
export interface ReceiptEvent {
|
||||||
interactionId: string;
|
interactionId: string;
|
||||||
|
/** The reader's IIOS actor UUID — an internal id, NOT comparable to a caller's userId. */
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
/** The reader's userId — the same id space clients hold, so they can ignore their own receipt.
|
||||||
|
* Optional: absent from servers older than the change that added it. */
|
||||||
|
userId?: string;
|
||||||
kind: 'READ' | 'DELIVERED';
|
kind: 'READ' | 'DELIVERED';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-messaging-ui",
|
"name": "@insignia/iios-messaging-ui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
|
BulkAddResult,
|
||||||
ChannelSummary,
|
ChannelSummary,
|
||||||
Conversation,
|
Conversation,
|
||||||
CreateChannelInput,
|
CreateChannelInput,
|
||||||
@@ -39,6 +40,15 @@ export interface MessagingAdapter {
|
|||||||
|
|
||||||
markRead(threadId: string, messageId: string): Promise<void>;
|
markRead(threadId: string, messageId: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Report which thread is in the foreground (null when none/blurred) so the backend can suppress
|
||||||
|
* push notifications for a thread you're actively viewing. Absent => presence isn't tracked. */
|
||||||
|
setFocus?(threadId: string | null): void;
|
||||||
|
|
||||||
|
/** Subscribe to activity across ALL of the caller's threads (not just the open one) — fires for
|
||||||
|
* every incoming message. Drives live unread + in-app notifications. Absent => no cross-thread
|
||||||
|
* awareness. Returns an unsubscribe fn. */
|
||||||
|
subscribeActivity?(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current user's actor id, or null if not yet known.
|
* The current user's actor id, or null if not yet known.
|
||||||
*
|
*
|
||||||
@@ -88,6 +98,11 @@ export interface MessagingAdapter {
|
|||||||
/** Add a person to a group or private channel. */
|
/** Add a person to a group or private channel. */
|
||||||
addMember?(threadId: string, userId: string): Promise<void>;
|
addMember?(threadId: string, userId: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Add many people in one call — powers "add everyone from another channel". The host is expected
|
||||||
|
* to enforce who may add (and who may read the source roster) server-side.
|
||||||
|
* Absent => the import-from-a-channel affordance is hidden; single add still works. */
|
||||||
|
addMembers?(threadId: string, userIds: string[]): Promise<BulkAddResult>;
|
||||||
|
|
||||||
/** Remove a person from a group or channel. */
|
/** Remove a person from a group or channel. */
|
||||||
removeMember?(threadId: string, userId: string): Promise<void>;
|
removeMember?(threadId: string, userId: string): Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { MessagingAdapter } from '../adapter';
|
import type { MessagingAdapter } from '../adapter';
|
||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
|
BulkAddResult,
|
||||||
ChannelSummary,
|
ChannelSummary,
|
||||||
ChannelVisibility,
|
ChannelVisibility,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -168,6 +169,22 @@ export class MockAdapter implements MessagingAdapter {
|
|||||||
if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId];
|
if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bulk add, mirroring the live door: already-members come back as `skipped`, never re-added. */
|
||||||
|
async addMembers(threadId: string, userIds: string[]): Promise<BulkAddResult> {
|
||||||
|
const t = this.threads.get(threadId);
|
||||||
|
if (!t) return { added: [], skipped: [], failed: [...userIds] };
|
||||||
|
const added: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
for (const id of [...new Set(userIds)]) {
|
||||||
|
if (t.participants.includes(id)) skipped.push(id);
|
||||||
|
else {
|
||||||
|
t.participants = [...t.participants, id];
|
||||||
|
added.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { added, skipped, failed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
async removeMember(threadId: string, userId: string): Promise<void> {
|
async removeMember(threadId: string, userId: string): Promise<void> {
|
||||||
const t = this.threads.get(threadId);
|
const t = this.threads.get(threadId);
|
||||||
if (t) t.participants = t.participants.filter((p) => p !== userId);
|
if (t) t.participants = t.participants.filter((p) => p !== userId);
|
||||||
@@ -253,8 +270,9 @@ export class MockAdapter implements MessagingAdapter {
|
|||||||
// No-op: nobody is typing back in a mock.
|
// No-op: nobody is typing back in a mock.
|
||||||
}
|
}
|
||||||
|
|
||||||
async markRead(): Promise<void> {
|
/** Echo a receipt the way a real server does, so the "ignore my own read" path is exercised. */
|
||||||
// No-op: the mock has no second party to report a read.
|
async markRead(threadId: string, messageId: string): Promise<void> {
|
||||||
|
this.emit(threadId, { kind: 'receipt', messageId, actorId: ME });
|
||||||
}
|
}
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { Composer } from './composer';
|
||||||
|
|
||||||
|
function mount(onSend = vi.fn().mockResolvedValue(undefined)) {
|
||||||
|
render(<Composer members={[]} canUpload={false} upload={vi.fn()} onSend={onSend} />);
|
||||||
|
return { onSend, box: screen.getByLabelText('Message') as HTMLTextAreaElement };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<Composer /> formatting + native text services', () => {
|
||||||
|
it('is a textarea with the platform spellchecker enabled', () => {
|
||||||
|
const { box } = mount();
|
||||||
|
expect(box.tagName).toBe('TEXTAREA');
|
||||||
|
// The red squiggle + right-click suggestions come from the OS via these attributes.
|
||||||
|
expect(box.getAttribute('spellcheck')).toBe('true');
|
||||||
|
expect(box.getAttribute('autocorrect')).toBe('on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT reuse .miu-textarea — that class belongs to the inbox mail composer', () => {
|
||||||
|
// Regression: sharing it let the mail rule (resize: vertical; min-height: 90px), which is
|
||||||
|
// declared later in the stylesheet, win at equal specificity and inflate the chat composer.
|
||||||
|
const { box } = mount();
|
||||||
|
expect(box.classList.contains('miu-composer-box')).toBe(true);
|
||||||
|
expect(box.classList.contains('miu-textarea')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Enter sends, Shift+Enter does not (it makes a newline)', async () => {
|
||||||
|
const { onSend, box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'hello' } });
|
||||||
|
|
||||||
|
fireEvent.keyDown(box, { key: 'Enter', shiftKey: true });
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.keyDown(box, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(onSend).toHaveBeenCalledWith('hello', expect.anything()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send mid-IME composition', () => {
|
||||||
|
const { onSend, box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'にほん' } });
|
||||||
|
fireEvent.keyDown(box, { key: 'Enter', isComposing: true });
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a toolbar button wraps the current selection in its marker', () => {
|
||||||
|
const { box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'make me bold' } });
|
||||||
|
box.setSelectionRange(8, 12); // "bold"
|
||||||
|
fireEvent.click(screen.getByLabelText('Bold (⌘B)'));
|
||||||
|
expect(box.value).toBe('make me *bold*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⌘B / ⌘I wrap the selection too', () => {
|
||||||
|
const { box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'hello world' } });
|
||||||
|
box.setSelectionRange(0, 5);
|
||||||
|
fireEvent.keyDown(box, { key: 'b', metaKey: true });
|
||||||
|
expect(box.value).toBe('*hello* world');
|
||||||
|
|
||||||
|
box.setSelectionRange(8, 13); // "world" shifted by the two markers
|
||||||
|
fireEvent.keyDown(box, { key: 'i', ctrlKey: true });
|
||||||
|
expect(box.value).toBe('*hello* _world_');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with nothing selected, a marker pair is inserted at the caret', () => {
|
||||||
|
const { box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'ab' } });
|
||||||
|
box.setSelectionRange(2, 2);
|
||||||
|
fireEvent.click(screen.getByLabelText('Italic (⌘I)'));
|
||||||
|
expect(box.value).toBe('ab__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the raw markers as plain text — formatting is a render concern', async () => {
|
||||||
|
const { onSend, box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'ship *today*' } });
|
||||||
|
fireEvent.keyDown(box, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(onSend).toHaveBeenCalledWith('ship *today*', expect.anything()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
SPECIAL_MENTIONS,
|
SPECIAL_MENTIONS,
|
||||||
insertMention,
|
insertMention,
|
||||||
@@ -13,6 +13,17 @@ interface Suggestion {
|
|||||||
insert: string;
|
insert: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keyboard shortcut → the marker it wraps the selection in. */
|
||||||
|
const SHORTCUTS: Record<string, string> = { b: '*', i: '_', e: '`' };
|
||||||
|
|
||||||
|
/** Toolbar affordances for the same markers (strikethrough is button-only — no common shortcut). */
|
||||||
|
const FORMAT_BUTTONS: Array<{ marker: string; label: string; title: string }> = [
|
||||||
|
{ marker: '*', label: 'B', title: 'Bold (⌘B)' },
|
||||||
|
{ marker: '_', label: 'I', title: 'Italic (⌘I)' },
|
||||||
|
{ marker: '~', label: 'S', title: 'Strikethrough' },
|
||||||
|
{ marker: '`', label: '‹›', title: 'Code (⌘E)' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
|
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
|
||||||
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
|
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
|
||||||
@@ -24,7 +35,7 @@ export function Composer({
|
|||||||
onSend,
|
onSend,
|
||||||
onTyping,
|
onTyping,
|
||||||
parentInteractionId,
|
parentInteractionId,
|
||||||
placeholder = 'Type a message… @ to mention',
|
placeholder = 'Type a message… @ to mention, *bold*',
|
||||||
}: {
|
}: {
|
||||||
members: Person[];
|
members: Person[];
|
||||||
canUpload: boolean;
|
canUpload: boolean;
|
||||||
@@ -40,6 +51,39 @@ export function Composer({
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Grow with the content up to the CSS max-height, then scroll — a chat box, not a fixed field.
|
||||||
|
// The box is border-box, but scrollHeight excludes the border, so add it back or every measure
|
||||||
|
// lands a couple of pixels short and the textarea shows a scrollbar it doesn't need.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
const border = el.offsetHeight - el.clientHeight;
|
||||||
|
el.style.height = `${el.scrollHeight + border}px`;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap the current selection in a marker (or, with nothing selected, drop in an empty pair and
|
||||||
|
* park the caret inside). Text stays plain — the marker characters ARE the format, so this never
|
||||||
|
* needs a rich-text model and the native spellchecker keeps working on the raw string.
|
||||||
|
*/
|
||||||
|
function wrapSelection(marker: string): void {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart ?? draft.length;
|
||||||
|
const end = el.selectionEnd ?? start;
|
||||||
|
const selected = draft.slice(start, end);
|
||||||
|
const next = `${draft.slice(0, start)}${marker}${selected}${marker}${draft.slice(end)}`;
|
||||||
|
setDraft(next);
|
||||||
|
// Restore a sensible selection after React re-renders the value.
|
||||||
|
const caret = selected ? start + marker.length + selected.length + marker.length : start + marker.length;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(selected ? start + marker.length : caret, selected ? caret - marker.length : caret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const query = trailingMentionQuery(draft);
|
const query = trailingMentionQuery(draft);
|
||||||
const suggestions = useMemo<Suggestion[]>(() => {
|
const suggestions = useMemo<Suggestion[]>(() => {
|
||||||
@@ -93,10 +137,26 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
if (showSuggest && e.key === 'Enter') {
|
// Formatting shortcuts, matching the toolbar. Cmd on macOS, Ctrl elsewhere.
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
const marker = SHORTCUTS[e.key.toLowerCase()];
|
||||||
|
if (marker) {
|
||||||
|
e.preventDefault();
|
||||||
|
wrapSelection(marker);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
if (showSuggest) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
pick(suggestions[0]!.insert);
|
pick(suggestions[0]!.insert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Enter sends; Shift+Enter (and IME composition) inserts a newline.
|
||||||
|
if (!e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +185,13 @@ export function Composer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="miu-format-bar" role="group" aria-label="Formatting">
|
||||||
|
{FORMAT_BUTTONS.map((b) => (
|
||||||
|
<button key={b.marker} type="button" className="miu-format-btn" title={b.title} aria-label={b.title} onClick={() => wrapSelection(b.marker)}>
|
||||||
|
{b.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="miu-composer-row">
|
<div className="miu-composer-row">
|
||||||
{canUpload ? (
|
{canUpload ? (
|
||||||
<>
|
<>
|
||||||
@@ -134,11 +201,18 @@ export function Composer({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<input
|
<textarea
|
||||||
className="miu-input"
|
ref={inputRef}
|
||||||
|
className="miu-input miu-composer-box"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label="Message"
|
aria-label="Message"
|
||||||
|
rows={1}
|
||||||
|
// Native platform text services: the browser/OS supplies the red squiggle, the right-click
|
||||||
|
// suggestions and "Add to dictionary", and mobile keyboards add autocorrect. Nothing to ship.
|
||||||
|
spellCheck
|
||||||
|
autoCorrect="on"
|
||||||
|
autoCapitalize="sentences"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDraft(e.target.value);
|
setDraft(e.target.value);
|
||||||
onTyping?.();
|
onTyping?.();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Conversation } from '../types';
|
import type { Conversation } from '../types';
|
||||||
|
import { stripMarkup } from '../rich-text';
|
||||||
|
|
||||||
/** Initials for the avatar chip — first letters of the first two words. */
|
/** Initials for the avatar chip — first letters of the first two words. */
|
||||||
function initials(title: string): string {
|
function initials(title: string): string {
|
||||||
@@ -37,7 +38,7 @@ export function ConversationList({
|
|||||||
</span>
|
</span>
|
||||||
<span className="miu-convrow-main">
|
<span className="miu-convrow-main">
|
||||||
<span className="miu-convrow-title">{c.title}</span>
|
<span className="miu-convrow-title">{c.title}</span>
|
||||||
{c.lastMessage ? <span className="miu-convrow-preview">{c.lastMessage}</span> : null}
|
{c.lastMessage ? <span className="miu-convrow-preview">{stripMarkup(c.lastMessage)}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
{c.unread > 0 ? (
|
{c.unread > 0 ? (
|
||||||
<span className="miu-badge" aria-label={`${c.unread} unread`}>
|
<span className="miu-badge" aria-label={`${c.unread} unread`}>
|
||||||
|
|||||||
@@ -37,4 +37,61 @@ describe('<ConversationSettings />', () => {
|
|||||||
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
|
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('“#” switches the picker to channels you belong to, excluding this one', async () => {
|
||||||
|
mount();
|
||||||
|
await screen.findByText('Dan Whitaker');
|
||||||
|
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
|
||||||
|
// Channels the mock user is in show up as import sources…
|
||||||
|
await screen.findByText('#general');
|
||||||
|
// …and the conversation being edited is never offered as its own source.
|
||||||
|
expect(screen.queryByText('#Storm crew')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports a channel roster only after confirmation, and reports what landed', async () => {
|
||||||
|
const a = mount();
|
||||||
|
await screen.findByText('Dan Whitaker');
|
||||||
|
const before = (await a.listMembers('th_mock_2')).length;
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
|
||||||
|
fireEvent.click(await screen.findByText('#general'));
|
||||||
|
|
||||||
|
// Staged, NOT applied — picking a channel must not mutate membership on its own.
|
||||||
|
const confirm = await screen.findByRole('button', { name: /^Add \d+$/ });
|
||||||
|
expect((await a.listMembers('th_mock_2')).length).toBe(before);
|
||||||
|
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect((await a.listMembers('th_mock_2')).length).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
await screen.findByText(/Added \d+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancelling a staged import leaves membership untouched', async () => {
|
||||||
|
const a = mount();
|
||||||
|
await screen.findByText('Dan Whitaker');
|
||||||
|
const before = (await a.listMembers('th_mock_2')).length;
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
|
||||||
|
fireEvent.click(await screen.findByText('#general'));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
||||||
|
|
||||||
|
await screen.findByLabelText('Search people'); // back to the picker
|
||||||
|
expect((await a.listMembers('th_mock_2')).length).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the channel-import affordance when the adapter cannot bulk-add', async () => {
|
||||||
|
const a = new MockAdapter();
|
||||||
|
// A host that implements single add but not addMembers => no import path offered.
|
||||||
|
(a as { addMembers?: unknown }).addMembers = undefined;
|
||||||
|
render(
|
||||||
|
<MessagingProvider adapter={a}>
|
||||||
|
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
|
||||||
|
</MessagingProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByText('Dan Whitaker');
|
||||||
|
expect(screen.getByLabelText('Search people').getAttribute('placeholder')).toBe('Search people…');
|
||||||
|
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
|
||||||
|
expect(screen.queryByText('#general')).toBeNull(); // '#' is just a search string here
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useAdapter } from '../provider';
|
import { useAdapter } from '../provider';
|
||||||
|
import { useConversations } from '../hooks/use-conversations';
|
||||||
import { ModalPortal } from './modal-portal';
|
import { ModalPortal } from './modal-portal';
|
||||||
import type { Person } from '../types';
|
import type { BulkAddResult, Conversation, Person } from '../types';
|
||||||
|
|
||||||
|
/** A source channel the caller belongs to, staged for a roster import once its members are read. */
|
||||||
|
interface PendingImport {
|
||||||
|
source: Conversation;
|
||||||
|
/** Members of the source who are NOT already here — the ones an import would actually add. */
|
||||||
|
newcomers: Person[];
|
||||||
|
/** Members of the source already in this conversation; reported so the count is never surprising. */
|
||||||
|
alreadyHere: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings for a group or channel (public + private): rename, member list, add/remove people, and
|
* Settings for a group or channel (public + private): rename, member list, add/remove people, and
|
||||||
@@ -30,9 +40,23 @@ export function ConversationSettings({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
|
|
||||||
|
const [pending, setPending] = useState<PendingImport | null>(null);
|
||||||
|
const [imported, setImported] = useState<BulkAddResult | null>(null);
|
||||||
|
const { conversations } = useConversations();
|
||||||
|
|
||||||
const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function';
|
const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function';
|
||||||
const canRename = typeof adapter.renameConversation === 'function';
|
const canRename = typeof adapter.renameConversation === 'function';
|
||||||
const canLeave = membership === 'channel' && typeof adapter.leaveChannel === 'function';
|
const canLeave = membership === 'channel' && typeof adapter.leaveChannel === 'function';
|
||||||
|
// Importing a roster needs both halves: read the source's members, and bulk-add them here.
|
||||||
|
const canImport = typeof adapter.addMembers === 'function' && typeof adapter.listMembers === 'function';
|
||||||
|
|
||||||
|
// The channels you belong to are the only valid import sources — this list is already
|
||||||
|
// membership-scoped (it is your own conversation list), so private channels appear iff you're in
|
||||||
|
// them, and the server re-checks the source-membership rule on the roster read regardless.
|
||||||
|
const sourceChannels = useMemo(
|
||||||
|
() => conversations.filter((c) => c.membership === 'channel' && c.threadId !== threadId),
|
||||||
|
[conversations, threadId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -51,7 +75,41 @@ export function ConversationSettings({
|
|||||||
}, [adapter, threadId, nonce]);
|
}, [adapter, threadId, nonce]);
|
||||||
|
|
||||||
const memberIds = useMemo(() => new Set(members.map((m) => m.id)), [members]);
|
const memberIds = useMemo(() => new Set(members.map((m) => m.id)), [members]);
|
||||||
|
// A leading '#' switches the picker from people to channels — the roster-import affordance.
|
||||||
|
const channelMode = canImport && q.trim().startsWith('#');
|
||||||
|
const channelQuery = q.trim().slice(1).toLowerCase();
|
||||||
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||||
|
const matchingChannels = sourceChannels.filter((c) => c.title.toLowerCase().includes(channelQuery));
|
||||||
|
|
||||||
|
/** Stage an import: read the source roster, then diff it against who is already here. */
|
||||||
|
async function stageImport(source: Conversation): Promise<void> {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const roster = await adapter.listMembers!(source.threadId);
|
||||||
|
setPending({
|
||||||
|
source,
|
||||||
|
newcomers: roster.filter((p) => !memberIds.has(p.id)),
|
||||||
|
alreadyHere: roster.filter((p) => memberIds.has(p.id)).length,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit the staged import. One bulk call; the result reports what actually landed. */
|
||||||
|
async function confirmImport(): Promise<void> {
|
||||||
|
if (!pending) return;
|
||||||
|
const ids = pending.newcomers.map((p) => p.id);
|
||||||
|
await run(async () => {
|
||||||
|
const res = await adapter.addMembers!(threadId, ids);
|
||||||
|
setImported(res);
|
||||||
|
setPending(null);
|
||||||
|
setQ('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function run(fn: () => Promise<void>): Promise<void> {
|
async function run(fn: () => Promise<void>): Promise<void> {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -109,23 +167,73 @@ export function ConversationSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage && pending ? (
|
||||||
|
// Confirm step — an import is an administrative action, so it never fires on a stray click.
|
||||||
|
<div className="miu-field">
|
||||||
|
<span className="miu-field-lbl">Add from #{pending.source.title}</span>
|
||||||
|
{pending.newcomers.length === 0 ? (
|
||||||
|
<div className="miu-empty">Everyone from #{pending.source.title} is already here.</div>
|
||||||
|
) : (
|
||||||
|
<div className="miu-empty">
|
||||||
|
Add {pending.newcomers.length} {pending.newcomers.length === 1 ? 'person' : 'people'} from #{pending.source.title}?
|
||||||
|
{pending.alreadyHere > 0 ? ` (${pending.alreadyHere} already here)` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="miu-composer-row">
|
||||||
|
<button type="button" className="miu-tab" disabled={busy} onClick={() => setPending(null)}>Cancel</button>
|
||||||
|
<button type="button" className="miu-send" disabled={busy || pending.newcomers.length === 0} onClick={() => void confirmImport()}>
|
||||||
|
Add {pending.newcomers.length > 0 ? pending.newcomers.length : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canManage && !pending ? (
|
||||||
<div className="miu-field">
|
<div className="miu-field">
|
||||||
<span className="miu-field-lbl">Add people</span>
|
<span className="miu-field-lbl">Add people</span>
|
||||||
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
|
<input
|
||||||
|
className="miu-input"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => { setQ(e.target.value); setImported(null); }}
|
||||||
|
placeholder={canImport ? 'Search people… or # for a channel' : 'Search people…'}
|
||||||
|
aria-label="Search people"
|
||||||
|
/>
|
||||||
<div className="miu-settings-list">
|
<div className="miu-settings-list">
|
||||||
{addable.length === 0 ? <div className="miu-empty">No one to add.</div> : null}
|
{channelMode ? (
|
||||||
{addable.slice(0, 25).map((p) => (
|
<>
|
||||||
<button key={p.id} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void run(() => adapter.addMember!(threadId, p.id))}>
|
{matchingChannels.length === 0 ? <div className="miu-empty">No channels to add from.</div> : null}
|
||||||
<span className="miu-settings-name">{p.name}</span>
|
{matchingChannels.slice(0, 25).map((c) => (
|
||||||
<span className="miu-pill">{p.kind}</span>
|
<button key={c.threadId} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void stageImport(c)}>
|
||||||
<span className="miu-settings-plus" aria-hidden="true">+</span>
|
<span className="miu-settings-name">#{c.title}</span>
|
||||||
</button>
|
<span className="miu-pill">channel</span>
|
||||||
))}
|
<span className="miu-settings-plus" aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{addable.length === 0 ? <div className="miu-empty">No one to add.</div> : null}
|
||||||
|
{addable.slice(0, 25).map((p) => (
|
||||||
|
<button key={p.id} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void run(() => adapter.addMember!(threadId, p.id))}>
|
||||||
|
<span className="miu-settings-name">{p.name}</span>
|
||||||
|
<span className="miu-pill">{p.kind}</span>
|
||||||
|
<span className="miu-settings-plus" aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{imported ? (
|
||||||
|
<div className="miu-empty">
|
||||||
|
Added {imported.added.length}
|
||||||
|
{imported.skipped.length > 0 ? ` · ${imported.skipped.length} already a member` : ''}
|
||||||
|
{imported.failed.length > 0 ? ` · ${imported.failed.length} failed` : ''}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="miu-modal-foot">
|
<div className="miu-modal-foot">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { highlightMentions } from '../mentions';
|
import { renderRichText } from '../rich-text';
|
||||||
import { PopoverPortal } from './popover-portal';
|
import { PopoverPortal } from './popover-portal';
|
||||||
import type { Attachment } from '../types';
|
import type { Attachment } from '../types';
|
||||||
import type { UiMessage } from '../hooks/use-messages';
|
import type { UiMessage } from '../hooks/use-messages';
|
||||||
@@ -65,7 +65,7 @@ export function MessageItem({
|
|||||||
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
|
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
|
||||||
<div className="miu-bubble-row">
|
<div className="miu-bubble-row">
|
||||||
<div className="miu-bubble">
|
<div className="miu-bubble">
|
||||||
{m.text ? highlightMentions(m.text, memberNames) : null}
|
{m.text ? renderRichText(m.text, memberNames) : null}
|
||||||
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
|
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
|
||||||
|
|||||||
@@ -40,6 +40,29 @@ export function Messenger({ focusThreadId }: { focusThreadId?: string | null } =
|
|||||||
setSelected(focusThreadId);
|
setSelected(focusThreadId);
|
||||||
}, [focusThreadId]);
|
}, [focusThreadId]);
|
||||||
|
|
||||||
|
// Presence: tell the backend which thread is foregrounded so it suppresses push for it. Null while
|
||||||
|
// browsing/composing, when the window is blurred, and on unmount (leaving the messenger).
|
||||||
|
useEffect(() => {
|
||||||
|
const active = browsing || composing ? null : selected;
|
||||||
|
adapter.setFocus?.(active);
|
||||||
|
const onBlur = (): void => adapter.setFocus?.(null);
|
||||||
|
const onFocus = (): void => adapter.setFocus?.(active);
|
||||||
|
window.addEventListener('blur', onBlur);
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
return () => {
|
||||||
|
adapter.setFocus?.(null);
|
||||||
|
window.removeEventListener('blur', onBlur);
|
||||||
|
window.removeEventListener('focus', onFocus);
|
||||||
|
};
|
||||||
|
}, [adapter, selected, browsing, composing]);
|
||||||
|
|
||||||
|
// Live unread + ordering: refresh the conversation list when any of the caller's threads gets a
|
||||||
|
// message (not just the open one).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adapter.subscribeActivity) return;
|
||||||
|
return adapter.subscribeActivity(() => refetch());
|
||||||
|
}, [adapter, refetch]);
|
||||||
|
|
||||||
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
||||||
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
||||||
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
|
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
|
||||||
|
|||||||
@@ -158,3 +158,39 @@ describe('useMessages', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('read receipts', () => {
|
||||||
|
// REGRESSION: the hook reported a read of the newest message regardless of author, so sending a
|
||||||
|
// message made the server echo a receipt for it and the sender's own bubble showed "Seen"
|
||||||
|
// immediately — in DMs, groups and channels alike, before anyone had opened it.
|
||||||
|
it('never reports a read of my own message', async () => {
|
||||||
|
const adapter = new MockAdapter();
|
||||||
|
const markRead = vi.spyOn(adapter, 'markRead');
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
markRead.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.send('a message from me');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The newest message is now mine — reading it would be reading myself.
|
||||||
|
const mine = result.current.messages.filter((m) => m.mine).map((m) => m.id);
|
||||||
|
for (const call of markRead.mock.calls) expect(mine).not.toContain(call[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark my message seen just because the receipt came back', async () => {
|
||||||
|
const adapter = new MockAdapter();
|
||||||
|
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.send('hello there');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mine = result.current.messages.find((m) => m.mine && m.text === 'hello there');
|
||||||
|
expect(mine).toBeDefined();
|
||||||
|
expect(result.current.seenIds.has(mine!.id)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -160,13 +160,19 @@ export function useMessages(threadId: string | null): MessagesState {
|
|||||||
if (threadId) adapter.sendTyping(threadId);
|
if (threadId) adapter.sendTyping(threadId);
|
||||||
}, [adapter, threadId]);
|
}, [adapter, threadId]);
|
||||||
|
|
||||||
// The newest acknowledged (non-pending) message id — what we report as read.
|
// The newest acknowledged (non-pending) message id from SOMEONE ELSE — what we report as read.
|
||||||
|
//
|
||||||
|
// Skipping my own messages is load-bearing, not tidiness: reporting a read of the message I just
|
||||||
|
// sent makes the server broadcast a receipt for it, and the sender's own client then paints it
|
||||||
|
// "Seen" the instant it is delivered, before anyone has looked at it.
|
||||||
const lastReadableId = useMemo(() => {
|
const lastReadableId = useMemo(() => {
|
||||||
for (let i = raw.length - 1; i >= 0; i--) {
|
for (let i = raw.length - 1; i >= 0; i--) {
|
||||||
if (!raw[i]!.pending) return raw[i]!.id;
|
const m = raw[i]!;
|
||||||
|
if (m.pending || isOwnMessage(m, currentActorId)) continue;
|
||||||
|
return m.id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [raw]);
|
}, [raw, currentActorId]);
|
||||||
|
|
||||||
// Report my read of the newest message (drives the other side's "seen" tick).
|
// Report my read of the newest message (drives the other side's "seen" tick).
|
||||||
// Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.
|
// Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export { useMessages } from './hooks/use-messages';
|
|||||||
export { useChannels } from './hooks/use-channels';
|
export { useChannels } from './hooks/use-channels';
|
||||||
export { useMembers } from './hooks/use-members';
|
export { useMembers } from './hooks/use-members';
|
||||||
export { isOwnMessage } from './types';
|
export { isOwnMessage } from './types';
|
||||||
|
// Message-body markup (bold/italic/strike/code/links + mentions) as a safe ReactNode tree.
|
||||||
|
export { renderRichText, safeHref, stripMarkup } from './rich-text';
|
||||||
|
|
||||||
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
|
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
|
||||||
export { Messenger } from './components/messenger';
|
export { Messenger } from './components/messenger';
|
||||||
@@ -30,6 +32,7 @@ export type { MessagesState, UiMessage } from './hooks/use-messages';
|
|||||||
export type { ChannelsState } from './hooks/use-channels';
|
export type { ChannelsState } from './hooks/use-channels';
|
||||||
export type {
|
export type {
|
||||||
Attachment,
|
Attachment,
|
||||||
|
BulkAddResult,
|
||||||
ChannelSummary,
|
ChannelSummary,
|
||||||
ChannelVisibility,
|
ChannelVisibility,
|
||||||
Conversation,
|
Conversation,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { renderRichText, safeHref, stripMarkup } from './rich-text';
|
||||||
|
|
||||||
|
function show(text: string, names: string[] = []) {
|
||||||
|
render(<div data-testid="out">{renderRichText(text, names)}</div>);
|
||||||
|
return screen.getByTestId('out');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderRichText', () => {
|
||||||
|
it('renders bold, italic and strikethrough', () => {
|
||||||
|
const el = show('*bold* _italic_ ~gone~');
|
||||||
|
expect(el.querySelector('strong')?.textContent).toBe('bold');
|
||||||
|
expect(el.querySelector('em')?.textContent).toBe('italic');
|
||||||
|
expect(el.querySelector('del')?.textContent).toBe('gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests styles', () => {
|
||||||
|
const el = show('*bold with _italic_ inside*');
|
||||||
|
const strong = el.querySelector('strong');
|
||||||
|
expect(strong?.textContent).toBe('bold with italic inside');
|
||||||
|
expect(strong?.querySelector('em')?.textContent).toBe('italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves markup inside code completely literal', () => {
|
||||||
|
const el = show('use `*not bold*` here');
|
||||||
|
expect(el.querySelector('code')?.textContent).toBe('*not bold*');
|
||||||
|
expect(el.querySelector('strong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a fenced code block', () => {
|
||||||
|
const el = show('```\nconst a = 1;\n```');
|
||||||
|
expect(el.querySelector('pre.miu-code-block')?.textContent).toBe('const a = 1;\n');
|
||||||
|
expect(el.querySelector('code')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat arithmetic or a lone marker as formatting', () => {
|
||||||
|
const el = show('5 * 3 = 15 and a lone * plus snake_case_name');
|
||||||
|
expect(el.querySelector('strong')).toBeNull();
|
||||||
|
expect(el.querySelector('em')).toBeNull();
|
||||||
|
expect(el.textContent).toBe('5 * 3 = 15 and a lone * plus snake_case_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linkifies http(s) and www URLs without swallowing trailing punctuation', () => {
|
||||||
|
const el = show('see https://example.com/a?b=1, ok');
|
||||||
|
const a = el.querySelector('a');
|
||||||
|
expect(a?.getAttribute('href')).toBe('https://example.com/a?b=1');
|
||||||
|
expect(a?.textContent).toBe('https://example.com/a?b=1');
|
||||||
|
expect(a?.getAttribute('rel')).toContain('noopener');
|
||||||
|
expect(el.textContent).toContain(', ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never renders a javascript: URL as a link (XSS guard)', () => {
|
||||||
|
expect(safeHref('javascript:alert(1)')).toBeNull();
|
||||||
|
expect(safeHref('data:text/html,<script>')).toBeNull();
|
||||||
|
expect(safeHref('https://ok.example')).toBe('https://ok.example/');
|
||||||
|
// and it is not linkified in message text either
|
||||||
|
const el = show('javascript:alert(1)');
|
||||||
|
expect(el.querySelector('a')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes nothing as HTML — angle brackets stay text', () => {
|
||||||
|
const el = show('<img src=x onerror=alert(1)>');
|
||||||
|
expect(el.querySelector('img')).toBeNull();
|
||||||
|
expect(el.textContent).toBe('<img src=x onerror=alert(1)>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still highlights @mentions alongside formatting', () => {
|
||||||
|
const el = show('*hi* @Sofia Ramirez', ['Sofia Ramirez']);
|
||||||
|
expect(el.querySelector('strong')?.textContent).toBe('hi');
|
||||||
|
expect(el.querySelector('.miu-mention')?.textContent).toBe('@Sofia Ramirez');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plain text unchanged when there is no markup', () => {
|
||||||
|
expect(show('just a normal message').textContent).toBe('just a normal message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripMarkup (one-line previews)', () => {
|
||||||
|
it('drops the markers so a preview never shows ~crazy~', () => {
|
||||||
|
expect(stripMarkup('~crazy~')).toBe('crazy');
|
||||||
|
expect(stripMarkup('*bold* and _italic_')).toBe('bold and italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps code and flattens a fenced block', () => {
|
||||||
|
expect(stripMarkup('run `npm ci` now')).toBe('run npm ci now');
|
||||||
|
expect(stripMarkup('```\nconst a = 1;\n```')).toBe('const a = 1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nesting and leaves ordinary text (and identifiers) alone', () => {
|
||||||
|
expect(stripMarkup('*bold with _italic_*')).toBe('bold with italic');
|
||||||
|
expect(stripMarkup('snake_case_name and 5 * 3')).toBe('snake_case_name and 5 * 3');
|
||||||
|
expect(stripMarkup('plain preview')).toBe('plain preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { highlightMentions } from './mentions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp-style lightweight markup for message bodies.
|
||||||
|
*
|
||||||
|
* *bold* _italic_ ~strike~ `code` ```code block``` plus bare URLs
|
||||||
|
*
|
||||||
|
* Messages stay PLAIN TEXT on the wire — this only affects rendering, so nothing already stored
|
||||||
|
* breaks and a client without this build just shows the marker characters.
|
||||||
|
*
|
||||||
|
* Security: this returns a ReactNode tree and never touches dangerouslySetInnerHTML, so message
|
||||||
|
* text can never inject markup. The one genuinely dangerous surface is links, where an attacker
|
||||||
|
* could otherwise smuggle `javascript:` — {@link safeHref} allows only http/https/mailto.
|
||||||
|
*
|
||||||
|
* Precedence (deliberate): code is tokenized first and its contents are left completely alone, so
|
||||||
|
* `*not bold*` inside backticks stays literal. Everything else may nest (*bold with _italic_*).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Only protocols that cannot execute script. Anything else renders as plain text, not a link. */
|
||||||
|
export function safeHref(raw: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare URL: stops before trailing punctuation so "see https://x.com." doesn't swallow the period.
|
||||||
|
const URL_RE = /\bhttps?:\/\/[^\s<>()]+[^\s<>().,;:!?'"]|\bwww\.[^\s<>()]+[^\s<>().,;:!?'"]/g;
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
/** The wrapping marker, e.g. '*' for bold. */
|
||||||
|
marker: string;
|
||||||
|
tag: 'strong' | 'em' | 'del';
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULES: Rule[] = [
|
||||||
|
{ marker: '*', tag: 'strong' },
|
||||||
|
{ marker: '_', tag: 'em' },
|
||||||
|
{ marker: '~', tag: 'del' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Escape a marker so it is literal inside a RegExp. */
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first inline span (`*bold*`, `_italic_`, `~strike~`) in `text`.
|
||||||
|
*
|
||||||
|
* Two guards keep everyday prose from being mangled:
|
||||||
|
* - the content must not start or end with whitespace, so "5 * 3 = 15" and a lone `*` are inert;
|
||||||
|
* - the markers must sit on word boundaries, so `snake_case_name` is NOT italicised (the bug
|
||||||
|
* every naive markdown renderer ships with).
|
||||||
|
*/
|
||||||
|
function firstSpan(text: string): { start: number; end: number; rule: Rule; inner: string } | null {
|
||||||
|
let best: { start: number; end: number; rule: Rule; inner: string } | null = null;
|
||||||
|
for (const rule of RULES) {
|
||||||
|
const m = esc(rule.marker);
|
||||||
|
// (lead)(marker)(inner)(marker) — `lead` keeps the boundary char out of the match itself.
|
||||||
|
const re = new RegExp(`(^|[^\\w${m}])${m}(?=\\S)([^${m}]*[^\\s${m}])${m}(?![\\w${m}])`);
|
||||||
|
const found = re.exec(text);
|
||||||
|
if (!found) continue;
|
||||||
|
const start = found.index + (found[1]?.length ?? 0);
|
||||||
|
if (best === null || start < best.start) {
|
||||||
|
best = { start, end: found.index + found[0].length, rule, inner: found[2] ?? '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same text with its formatting markers removed, for places that show a one-line plain-text
|
||||||
|
* preview (conversation list, notifications) where `~crazy~` must read as "crazy" — you cannot
|
||||||
|
* render nodes into those, so the markers have to come off rather than be styled.
|
||||||
|
*/
|
||||||
|
export function stripMarkup(text: string): string {
|
||||||
|
const noCode = text
|
||||||
|
.replace(/```([\s\S]*?)```/g, (_m, code: string) => code.trim())
|
||||||
|
.replace(/`([^`\n]+)`/g, '$1');
|
||||||
|
return stripSpans(noCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively drop *bold* / _italic_ / ~strike~ markers, honouring the same word-boundary rule. */
|
||||||
|
function stripSpans(text: string): string {
|
||||||
|
const span = firstSpan(text);
|
||||||
|
if (!span) return text;
|
||||||
|
return text.slice(0, span.start) + stripSpans(span.inner) + stripSpans(text.slice(span.end));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linkify + mention-highlight a run of text that carries no other markup. */
|
||||||
|
function plain(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
URL_RE.lastIndex = 0;
|
||||||
|
while ((m = URL_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > last) out.push(...highlightMentions(text.slice(last, m.index), memberNames));
|
||||||
|
const raw = m[0];
|
||||||
|
const href = safeHref(raw.startsWith('www.') ? `https://${raw}` : raw);
|
||||||
|
out.push(
|
||||||
|
href ? (
|
||||||
|
<a key={keyed()} className="miu-link" href={href} target="_blank" rel="noopener noreferrer nofollow">
|
||||||
|
{raw}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
raw
|
||||||
|
),
|
||||||
|
);
|
||||||
|
last = m.index + raw.length;
|
||||||
|
}
|
||||||
|
if (last < text.length) out.push(...highlightMentions(text.slice(last), memberNames));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tokenize one segment that is known to contain no code, recursing so styles can nest. */
|
||||||
|
function inline(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
|
||||||
|
const span = firstSpan(text);
|
||||||
|
if (!span) return plain(text, memberNames, keyed);
|
||||||
|
const { start, end, rule, inner } = span;
|
||||||
|
const Tag = rule.tag;
|
||||||
|
return [
|
||||||
|
...(start > 0 ? inline(text.slice(0, start), memberNames, keyed) : []),
|
||||||
|
<Tag key={keyed()}>{inline(inner, memberNames, keyed)}</Tag>,
|
||||||
|
...(end < text.length ? inline(text.slice(end), memberNames, keyed) : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render message text as a safe ReactNode tree: code first (its contents stay literal), then
|
||||||
|
* nested bold/italic/strike, then links and @mentions.
|
||||||
|
*/
|
||||||
|
export function renderRichText(text: string, memberNames: string[] = []): ReactNode[] {
|
||||||
|
let n = 0;
|
||||||
|
const keyed = (): string => `rt${n++}`;
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
// ```block``` or `inline` — matched together so the longer fence wins.
|
||||||
|
const CODE_RE = /```([\s\S]+?)```|`([^`\n]+)`/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = CODE_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > last) out.push(...inline(text.slice(last, m.index), memberNames, keyed));
|
||||||
|
if (m[1] !== undefined) {
|
||||||
|
out.push(
|
||||||
|
<pre key={keyed()} className="miu-code-block">
|
||||||
|
<code>{m[1].replace(/^\n/, '')}</code>
|
||||||
|
</pre>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
out.push(
|
||||||
|
<code key={keyed()} className="miu-code">
|
||||||
|
{m[2]}
|
||||||
|
</code>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) out.push(...inline(text.slice(last), memberNames, keyed));
|
||||||
|
return out.length > 0 ? out : [text];
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
--miu-accent: #fda913;
|
--miu-accent: #fda913;
|
||||||
--miu-accent-text: #1a1206;
|
--miu-accent-text: #1a1206;
|
||||||
--miu-radius: 12px;
|
--miu-radius: 12px;
|
||||||
|
/* Collapsed composer height — textarea, attach and send all use this so they never diverge. */
|
||||||
|
--miu-composer-h: 38px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -371,13 +373,17 @@
|
|||||||
.miu-composer-row {
|
.miu-composer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
/* Pin the controls to the bottom so a growing textarea never stretches them (WhatsApp). */
|
||||||
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
.miu-file-input {
|
.miu-file-input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.miu-attach-btn {
|
.miu-attach-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 38px;
|
box-sizing: border-box;
|
||||||
|
width: var(--miu-composer-h);
|
||||||
|
height: var(--miu-composer-h);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--miu-border);
|
border: 1px solid var(--miu-border);
|
||||||
background: var(--miu-panel);
|
background: var(--miu-panel);
|
||||||
@@ -515,6 +521,78 @@ button.miu-attach-dl:hover {
|
|||||||
.miu-input:focus {
|
.miu-input:focus {
|
||||||
border-color: var(--miu-accent);
|
border-color: var(--miu-accent);
|
||||||
}
|
}
|
||||||
|
/* Chat box: one row by default, grows with content (JS sets height), then scrolls.
|
||||||
|
border-box so min/max-height are the REAL height — under the default content-box the padding
|
||||||
|
and border are added on top, which made a "38px" box render ~58px and dragged the row with it. */
|
||||||
|
/* Composer box only. NOTE: .miu-textarea is already taken by the inbox mail composer further
|
||||||
|
down (resize: vertical; min-height: 90px) — sharing it made that rule win and blow this up. */
|
||||||
|
.miu-composer-box {
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: none;
|
||||||
|
/* Collapsed height must equal the single-line <input> this replaced: 20px of line + 16px
|
||||||
|
padding + 2px border = 38px. Padding is tightened from .miu-input's 9px so a 14px/1.4 line
|
||||||
|
fits the content box exactly — at 9px it overflowed and forced a scrollbar. */
|
||||||
|
padding: 8px 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: var(--miu-composer-h);
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.miu-format-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 2px 4px;
|
||||||
|
}
|
||||||
|
.miu-format-btn {
|
||||||
|
min-width: 26px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.miu-format-btn:hover {
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
color: var(--miu-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rendered message formatting ── */
|
||||||
|
.miu-code {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
.miu-code-block {
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.miu-link {
|
||||||
|
color: var(--miu-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.miu-msg.is-mine .miu-bubble .miu-link {
|
||||||
|
color: var(--miu-accent-text);
|
||||||
|
}
|
||||||
|
/* On your own (accent-filled) bubble the panel background would sit dark-on-dark against the dark
|
||||||
|
accent text — tint the bubble instead so the chip reads on any accent colour. */
|
||||||
|
.miu-msg.is-mine .miu-bubble .miu-code,
|
||||||
|
.miu-msg.is-mine .miu-bubble .miu-code-block {
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
color: var(--miu-accent-text);
|
||||||
|
}
|
||||||
.miu-send {
|
.miu-send {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -524,6 +602,11 @@ button.miu-attach-dl:hover {
|
|||||||
color: var(--miu-accent-text);
|
color: var(--miu-accent-text);
|
||||||
background: var(--miu-accent);
|
background: var(--miu-accent);
|
||||||
}
|
}
|
||||||
|
.miu-composer .miu-send {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: var(--miu-composer-h);
|
||||||
|
}
|
||||||
.miu-send:disabled {
|
.miu-send:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ export interface Conversation {
|
|||||||
topic?: string | null;
|
topic?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a bulk member import, reported per user so a partial result is never silent.
|
||||||
|
* `skipped` were already members (which makes a repeat import a no-op); `failed` could not be added.
|
||||||
|
*/
|
||||||
|
export interface BulkAddResult {
|
||||||
|
added: string[];
|
||||||
|
skipped: string[];
|
||||||
|
failed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */
|
/** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */
|
||||||
export interface ChannelSummary {
|
export interface ChannelSummary {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MessageGateway } from './message.gateway';
|
|||||||
import type { MessageService, MessagePrincipal } from './message.service';
|
import type { MessageService, MessagePrincipal } from './message.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import type { OutboxBus } from '../outbox/outbox.bus';
|
import type { OutboxBus } from '../outbox/outbox.bus';
|
||||||
import type { PresenceService } from '../notifications/presence.service';
|
import type { PresencePort } from '../notifications/presence.port';
|
||||||
|
|
||||||
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
|
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
|
||||||
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
|
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
|
||||||
@@ -54,7 +54,7 @@ function gatewayReturning(principal: MessagePrincipal): MessageGateway {
|
|||||||
undefined as unknown as MessageService,
|
undefined as unknown as MessageService,
|
||||||
session,
|
session,
|
||||||
undefined as unknown as OutboxBus,
|
undefined as unknown as OutboxBus,
|
||||||
undefined as unknown as PresenceService,
|
undefined as unknown as PresencePort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ConnectedSocket,
|
ConnectedSocket,
|
||||||
MessageBody,
|
MessageBody,
|
||||||
@@ -16,7 +16,7 @@ import { logJson } from '../observability/logger';
|
|||||||
import { MessageService, type MessagePrincipal } from './message.service';
|
import { MessageService, type MessagePrincipal } from './message.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import { OutboxBus } from '../outbox/outbox.bus';
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
import { PresenceService } from '../notifications/presence.service';
|
import { PRESENCE_PORT, type PresencePort } from '../notifications/presence.port';
|
||||||
|
|
||||||
interface SocketState {
|
interface SocketState {
|
||||||
principal: MessagePrincipal;
|
principal: MessagePrincipal;
|
||||||
@@ -39,7 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
private readonly messages: MessageService,
|
private readonly messages: MessageService,
|
||||||
private readonly session: SessionVerifier,
|
private readonly session: SessionVerifier,
|
||||||
private readonly bus: OutboxBus,
|
private readonly bus: OutboxBus,
|
||||||
private readonly presence: PresenceService,
|
@Inject(PRESENCE_PORT) private readonly presence: PresencePort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -77,7 +77,8 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket): void {
|
handleDisconnect(client: Socket): void {
|
||||||
this.presence.clearSocket(client.id);
|
// Fire-and-forget: presence is best-effort and must not block the disconnect path.
|
||||||
|
void this.presence.clearSocket(client.id).catch((err) => this.logger.warn(`presence clear failed: ${(err as Error).message}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
|
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
|
||||||
@@ -85,7 +86,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
|
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
|
||||||
const state = client.data as SocketState | undefined;
|
const state = client.data as SocketState | undefined;
|
||||||
if (!state?.principal) return;
|
if (!state?.principal) return;
|
||||||
this.presence.setFocus(client.id, state.principal.userId, body?.threadId ?? null);
|
void this.presence
|
||||||
|
.setFocus(client.id, state.principal.userId, body?.threadId ?? null)
|
||||||
|
.catch((err) => this.logger.warn(`presence set failed: ${(err as Error).message}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('open_thread')
|
@SubscribeMessage('open_thread')
|
||||||
@@ -156,7 +159,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
const r = await this.messages.markRead(body.threadId, principal, body.interactionId);
|
const r = await this.messages.markRead(body.threadId, principal, body.interactionId);
|
||||||
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, kind: 'READ' });
|
// userId as well as actorId: actorId is an IIOS actor UUID, but clients hold the caller's
|
||||||
|
// USERID, so without this they cannot tell their own receipt from someone else's.
|
||||||
|
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, userId: principal.userId, kind: 'READ' });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +169,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
const r = await this.messages.markDelivered(body.threadId, principal, body.interactionId);
|
const r = await this.messages.markDelivered(body.threadId, principal, body.interactionId);
|
||||||
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, kind: 'DELIVERED' });
|
// userId as well as actorId: actorId is an IIOS actor UUID, but clients hold the caller's
|
||||||
|
// USERID, so without this they cannot tell their own receipt from someone else's.
|
||||||
|
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, userId: principal.userId, kind: 'DELIVERED' });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,21 @@ import { MessageService } from './message.service';
|
|||||||
import { MessageGateway } from './message.gateway';
|
import { MessageGateway } from './message.gateway';
|
||||||
import { OutboxModule } from '../outbox/outbox.module';
|
import { OutboxModule } from '../outbox/outbox.module';
|
||||||
import { PresenceService } from '../notifications/presence.service';
|
import { PresenceService } from '../notifications/presence.service';
|
||||||
|
import { RedisPresenceService } from '../notifications/redis-presence.service';
|
||||||
|
import { PRESENCE_PORT } from '../notifications/presence.port';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PRESENCE_PORT is single-instance in-memory by default; with REDIS_URL set it's Redis-backed so
|
||||||
|
* "who is viewing what" is shared across replicas (mirrors the socket.io Redis adapter gating).
|
||||||
|
*/
|
||||||
|
const presenceProvider = {
|
||||||
|
provide: PRESENCE_PORT,
|
||||||
|
useFactory: () => (process.env.REDIS_URL ? new RedisPresenceService(process.env.REDIS_URL) : new PresenceService()),
|
||||||
|
};
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
providers: [MessageService, MessageGateway, PresenceService],
|
providers: [MessageService, MessageGateway, presenceProvider],
|
||||||
exports: [MessageService, PresenceService],
|
exports: [MessageService, PRESENCE_PORT],
|
||||||
})
|
})
|
||||||
export class MessageModule {}
|
export class MessageModule {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
|
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -74,6 +74,10 @@ export interface OpenThreadResult {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MessageService {
|
export class MessageService {
|
||||||
|
/** Ceiling on one bulk participant import — bounds the work per request and the blast radius of a
|
||||||
|
* mistaken import. Raise here if a larger roster copy is ever needed. */
|
||||||
|
static readonly MAX_BULK_PARTICIPANTS = 200;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||||
@@ -167,6 +171,76 @@ export class MessageService {
|
|||||||
return { threadId, participantCount: participantCount + 1 };
|
return { threadId, participantCount: participantCount + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk sibling of {@link addParticipant}: add many users in ONE governed operation. The app uses
|
||||||
|
* this to import a roster (e.g. "add everyone from that channel"); the kernel stays generic — it
|
||||||
|
* receives an explicit list of userIds and never learns where the list came from.
|
||||||
|
*
|
||||||
|
* Deliberately NOT atomic: a single unresolvable user must not sink the whole import, so each is
|
||||||
|
* attempted independently and the outcome is reported per user. Already-members are `skipped`,
|
||||||
|
* which makes a re-run a no-op.
|
||||||
|
*/
|
||||||
|
async addParticipants(
|
||||||
|
threadId: string,
|
||||||
|
principal: MessagePrincipal,
|
||||||
|
targetUserIds: string[],
|
||||||
|
role = 'MEMBER',
|
||||||
|
): Promise<{ threadId: string; added: string[]; skipped: string[]; failed: string[]; participantCount: number }> {
|
||||||
|
const unique = [...new Set(targetUserIds.map((u) => u.trim()).filter(Boolean))];
|
||||||
|
if (unique.length === 0) throw new BadRequestException('at least one userId is required');
|
||||||
|
if (unique.length > MessageService.MAX_BULK_PARTICIPANTS) {
|
||||||
|
throw new BadRequestException(`at most ${MessageService.MAX_BULK_PARTICIPANTS} participants can be added at once`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
|
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
|
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
||||||
|
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
|
||||||
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
|
||||||
|
// ONE decision for the whole batch — `targetCount` lets policy reason about the resulting size
|
||||||
|
// (e.g. the dm two-person cap) instead of being asked the same question N times.
|
||||||
|
await decideOrThrow(this.ports, {
|
||||||
|
action: 'iios.thread.participant.add',
|
||||||
|
threadId,
|
||||||
|
scopeId: thread.scopeId,
|
||||||
|
membership,
|
||||||
|
participantCount,
|
||||||
|
callerRole: callerP?.participantRole,
|
||||||
|
targetCount: unique.length,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
const added: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const targetUserId of unique) {
|
||||||
|
try {
|
||||||
|
const target = await this.actors.resolveActor(scope.id, {
|
||||||
|
userId: targetUserId,
|
||||||
|
appId: principal.appId,
|
||||||
|
orgId: principal.orgId,
|
||||||
|
tenantId: principal.tenantId,
|
||||||
|
displayName: targetUserId,
|
||||||
|
});
|
||||||
|
const existing = await this.prisma.iiosThreadParticipant.findUnique({
|
||||||
|
where: { threadId_actorId: { threadId, actorId: target.id } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
skipped.push(targetUserId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.actors.ensureParticipant(threadId, target.id, role);
|
||||||
|
added.push(targetUserId);
|
||||||
|
} catch {
|
||||||
|
failed.push(targetUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { threadId, added, skipped, failed, participantCount: participantCount + added.length };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Governed thread rename (a generic subject update). Policy decides who may rename — for a
|
* Governed thread rename (a generic subject update). Policy decides who may rename — for a
|
||||||
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
|
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
|
||||||
@@ -224,7 +298,19 @@ export class MessageService {
|
|||||||
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
|
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
if (!thread) throw new NotFoundException('thread not found');
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
|
// Roster reads are membership-governed (a private channel's members must not be enumerable by
|
||||||
|
// a non-member), so the decision carries the caller's role + the thread's opaque attributes.
|
||||||
|
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
|
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
||||||
|
const meta = thread.metadata as { membership?: string; visibility?: string } | null;
|
||||||
|
await decideOrThrow(this.ports, {
|
||||||
|
action: 'iios.thread.participant.list',
|
||||||
|
threadId,
|
||||||
|
scopeId: thread.scopeId,
|
||||||
|
membership: meta?.membership,
|
||||||
|
visibility: meta?.visibility,
|
||||||
|
callerRole: callerP?.participantRole,
|
||||||
|
});
|
||||||
const parts = await this.prisma.iiosThreadParticipant.findMany({
|
const parts = await this.prisma.iiosThreadParticipant.findMany({
|
||||||
where: { threadId },
|
where: { threadId },
|
||||||
include: { actor: { include: { sourceHandle: true } } },
|
include: { actor: { include: { sourceHandle: true } } },
|
||||||
|
|||||||
@@ -146,6 +146,61 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
|||||||
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
|
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('roster reads are membership-governed: a non-member cannot list a private channel’s members', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' });
|
||||||
|
// bob is not a member — he must not be able to enumerate who is.
|
||||||
|
await expect(s.listParticipants(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
await s.addParticipant(priv, alice, 'bob');
|
||||||
|
expect((await s.listParticipants(priv, bob)).map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
|
||||||
|
|
||||||
|
// a PUBLIC channel's roster stays open (it is discoverable anyway)
|
||||||
|
const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
|
||||||
|
expect((await s.listParticipants(pub, bob)).map((m) => m.userId)).toEqual(['alice']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addParticipants: bulk-adds in one governed call, skipping existing members (re-run is a no-op)', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'ops', creatorRole: 'ADMIN' });
|
||||||
|
await s.addParticipant(threadId, alice, 'bob'); // bob is already in
|
||||||
|
|
||||||
|
const res = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']);
|
||||||
|
expect(res.added.sort()).toEqual(['carol', 'dave']);
|
||||||
|
expect(res.skipped).toEqual(['bob']);
|
||||||
|
expect(res.failed).toEqual([]);
|
||||||
|
expect(res.participantCount).toBe(4); // alice, bob, carol, dave
|
||||||
|
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4);
|
||||||
|
|
||||||
|
// idempotent: a second identical import adds nobody
|
||||||
|
const again = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']);
|
||||||
|
expect(again.added).toEqual([]);
|
||||||
|
expect(again.skipped.sort()).toEqual(['bob', 'carol', 'dave']);
|
||||||
|
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addParticipants is governed by the same policy as a single add (a plain member is denied)', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await s.addParticipant(threadId, alice, 'bob'); // bob joins as a plain MEMBER
|
||||||
|
await expect(s.addParticipants(threadId, bob, ['carol', 'dave'])).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addParticipants rejects an empty list and anything over the batch cap', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, creatorRole: 'ADMIN' });
|
||||||
|
await expect(s.addParticipants(threadId, alice, [])).rejects.toThrow(/at least one/i);
|
||||||
|
const tooMany = Array.from({ length: MessageService.MAX_BULK_PARTICIPANTS + 1 }, (_, n) => `user_${n}`);
|
||||||
|
await expect(s.addParticipants(threadId, alice, tooMany)).rejects.toThrow(/at most/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addParticipants respects the dm two-person cap for the whole batch', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
|
||||||
|
// alice is alone; adding two at once would make three — policy must deny the batch.
|
||||||
|
await expect(s.addParticipants(threadId, alice, ['bob', 'carol'])).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
expect((await s.addParticipants(threadId, alice, ['bob'])).added).toEqual(['bob']);
|
||||||
|
});
|
||||||
|
|
||||||
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
||||||
const s = gov();
|
const s = gov();
|
||||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ async function sentEvents(): Promise<CloudEvent[]> {
|
|||||||
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
|
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
|
||||||
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
|
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
|
||||||
}
|
}
|
||||||
|
/** A synthetic interaction.normalized event (the shape ingest/mail emits) for an existing interaction. */
|
||||||
|
function normalizedEvent(interactionId: string, threadId: string, kind: string): CloudEvent {
|
||||||
|
return {
|
||||||
|
specversion: '1.0',
|
||||||
|
id: `evt_norm_${interactionId}`,
|
||||||
|
type: IIOS_EVENTS.interactionNormalized,
|
||||||
|
source: 'iios/ingest/test',
|
||||||
|
time: '2026-01-01T00:00:00.000Z',
|
||||||
|
insignia: { idempotencyKey: `norm:${interactionId}` },
|
||||||
|
data: { interactionId, threadId, kind },
|
||||||
|
} as CloudEvent;
|
||||||
|
}
|
||||||
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
|
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
|
||||||
const deliver = vi.fn().mockResolvedValue(deliverResult);
|
const deliver = vi.fn().mockResolvedValue(deliverResult);
|
||||||
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
|
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
|
||||||
@@ -51,7 +63,7 @@ describe('NotificationProjector', () => {
|
|||||||
await seedSub('bob');
|
await seedSub('bob');
|
||||||
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
|
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(deliver).toHaveBeenCalledOnce();
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||||
});
|
});
|
||||||
@@ -63,7 +75,7 @@ describe('NotificationProjector', () => {
|
|||||||
await seedSub('bob');
|
await seedSub('bob');
|
||||||
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(deliver).not.toHaveBeenCalled();
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +86,7 @@ describe('NotificationProjector', () => {
|
|||||||
await seedSub('bob');
|
await seedSub('bob');
|
||||||
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
|
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(deliver).toHaveBeenCalledOnce();
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +100,7 @@ describe('NotificationProjector', () => {
|
|||||||
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
|
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
const evs = await sentEvents();
|
const evs = await sentEvents();
|
||||||
await proj.onMessageSent(evs[evs.length - 1]!); // project the reply
|
await proj.onEvent(evs[evs.length - 1]!); // project the reply
|
||||||
expect(deliver).toHaveBeenCalledOnce();
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||||
});
|
});
|
||||||
@@ -102,7 +114,7 @@ describe('NotificationProjector', () => {
|
|||||||
const presence = new PresenceService();
|
const presence = new PresenceService();
|
||||||
presence.setFocus('sockB', 'bob', threadId);
|
presence.setFocus('sockB', 'bob', threadId);
|
||||||
const { proj, deliver } = makeProjector(presence);
|
const { proj, deliver } = makeProjector(presence);
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(deliver).not.toHaveBeenCalled();
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,7 +126,31 @@ describe('NotificationProjector', () => {
|
|||||||
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
|
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
|
||||||
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mail (interaction.normalized, kind EMAIL): notifies the recipient even in a group thread', async () => {
|
||||||
|
// A group thread would NOT notify bob on message.sent (proven above); the EMAIL branch always does.
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
const sent = await m.send(threadId, alice, { content: 'your invoice is attached' }, 'k1');
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
await proj.onEvent(normalizedEvent(sent.id, threadId, 'EMAIL'));
|
||||||
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
|
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interaction.normalized that is NOT mail (kind MESSAGE): does not notify', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
const sent = await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
await proj.onEvent(normalizedEvent(sent.id, threadId, 'MESSAGE'));
|
||||||
expect(deliver).not.toHaveBeenCalled();
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,7 +161,7 @@ describe('NotificationProjector', () => {
|
|||||||
await seedSub('bob');
|
await seedSub('bob');
|
||||||
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||||
const { proj } = makeProjector(new PresenceService(), 'gone');
|
const { proj } = makeProjector(new PresenceService(), 'gone');
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
|
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { OutboxBus } from '../outbox/outbox.bus';
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
import { DlqService } from '../outbox/dlq.service';
|
import { DlqService } from '../outbox/dlq.service';
|
||||||
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||||
import { PresenceService } from './presence.service';
|
import { PRESENCE_PORT, type PresencePort } from './presence.port';
|
||||||
import { NOTIFICATION_PORT, type NotificationPort } from './notification.port';
|
import { NOTIFICATION_PORT, type NotificationPort } from './notification.port';
|
||||||
|
|
||||||
interface MsgData {
|
interface MsgData {
|
||||||
@@ -14,14 +14,22 @@ interface MsgData {
|
|||||||
mentions?: string[];
|
mentions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NormalizedData {
|
||||||
|
interactionId: string;
|
||||||
|
threadId: string;
|
||||||
|
kind?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns `message.sent` into push notifications for absent recipients. Three gates:
|
* Turns messenger sends AND inbound mail into push notifications for absent recipients:
|
||||||
* 1. policy — DM always; group only if @mentioned or a reply to that recipient
|
* - `message.sent` → messenger policy (DM always; group only if @mentioned or reply-to-you)
|
||||||
* 2. presence — skip if the recipient is currently focused on that thread
|
* - `interaction.normalized` (kind EMAIL) → mail always notifies the recipient (a directed 1:1)
|
||||||
* 3. mute — skip if the recipient muted the thread
|
* Every candidate then passes two more gates before delivery:
|
||||||
|
* presence — skip if the recipient is currently focused on that thread
|
||||||
|
* mute — skip if the recipient muted the thread
|
||||||
* Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it.
|
* Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it.
|
||||||
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group is read
|
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group / mail is read
|
||||||
* from the opaque `membership` thread attribute here in the notification *policy*, not the kernel.
|
* from opaque thread attributes here in the notification *policy*, not the kernel.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationProjector implements OnModuleInit {
|
export class NotificationProjector implements OnModuleInit {
|
||||||
@@ -32,36 +40,63 @@ export class NotificationProjector implements OnModuleInit {
|
|||||||
private readonly bus: OutboxBus,
|
private readonly bus: OutboxBus,
|
||||||
private readonly dlq: DlqService,
|
private readonly dlq: DlqService,
|
||||||
private readonly cursor: ProjectionCursorService,
|
private readonly cursor: ProjectionCursorService,
|
||||||
private readonly presence: PresenceService,
|
@Inject(PRESENCE_PORT) private readonly presence: PresencePort,
|
||||||
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
|
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
this.dlq.registerHandler(this.consumer, (e) => this.onMessageSent(e));
|
this.dlq.registerHandler(this.consumer, (e) => this.onEvent(e));
|
||||||
this.bus.on(IIOS_EVENTS.messageSent, (p) =>
|
this.bus.on(IIOS_EVENTS.messageSent, (p) =>
|
||||||
void this.onMessageSent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
|
void this.onEvent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
|
||||||
|
);
|
||||||
|
// Mail (external email + app-to-app) lands via ingest, which emits interaction.normalized — not
|
||||||
|
// message.sent — so subscribe here too and filter to EMAIL inside apply.
|
||||||
|
this.bus.on(IIOS_EVENTS.interactionNormalized, (p) =>
|
||||||
|
void this.onEvent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onMessageSent(event: CloudEvent): Promise<void> {
|
async onEvent(event: CloudEvent): Promise<void> {
|
||||||
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
|
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
|
||||||
await this.apply(event);
|
await this.apply(event);
|
||||||
await this.cursor.advance(this.consumer, event);
|
await this.cursor.advance(this.consumer, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async apply(event: CloudEvent): Promise<void> {
|
private async apply(event: CloudEvent): Promise<void> {
|
||||||
|
if (event.type === IIOS_EVENTS.messageSent) return this.applyMessage(event);
|
||||||
|
if (event.type === IIOS_EVENTS.interactionNormalized) return this.applyMail(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messenger send: DM always notifies; group only on @mention or reply-to-you. */
|
||||||
|
private async applyMessage(event: CloudEvent): Promise<void> {
|
||||||
const data = event.data as MsgData;
|
const data = event.data as MsgData;
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
|
||||||
if (!thread) return;
|
if (!thread) return;
|
||||||
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
await this.notify(data.threadId, data.interactionId, data.senderActorId, data.mentions ?? [], membership === 'dm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inbound mail (kind EMAIL): a directed message — always notify the non-sender recipient(s). */
|
||||||
|
private async applyMail(event: CloudEvent): Promise<void> {
|
||||||
|
const data = event.data as NormalizedData;
|
||||||
|
if (data.kind !== 'EMAIL') return; // other normalized interactions aren't mail — ignore
|
||||||
|
const sender = await this.prisma.iiosInteraction.findUnique({ where: { id: data.interactionId }, select: { actorId: true } });
|
||||||
|
if (!sender?.actorId) return;
|
||||||
|
await this.notify(data.threadId, data.interactionId, sender.actorId, [], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared fan-out: load the message, then for every participant but the sender apply the policy
|
||||||
|
* (alwaysNotify OR @mentioned OR replied-to-you), presence, and mute gates before delivering.
|
||||||
|
*/
|
||||||
|
private async notify(threadId: string, interactionId: string, senderActorId: string, mentions: string[], alwaysNotify: boolean): Promise<void> {
|
||||||
|
const data = { threadId, interactionId, senderActorId };
|
||||||
const interaction = await this.prisma.iiosInteraction.findUnique({
|
const interaction = await this.prisma.iiosInteraction.findUnique({
|
||||||
where: { id: data.interactionId },
|
where: { id: data.interactionId },
|
||||||
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
||||||
});
|
});
|
||||||
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
|
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
|
||||||
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
|
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
|
||||||
const mentions = data.mentions ?? [];
|
|
||||||
|
|
||||||
// Parent author (for reply-to-you) — one lookup.
|
// Parent author (for reply-to-you) — one lookup.
|
||||||
let parentAuthorActorId: string | null = null;
|
let parentAuthorActorId: string | null = null;
|
||||||
@@ -85,10 +120,10 @@ export class NotificationProjector implements OnModuleInit {
|
|||||||
// gate 1 — policy
|
// gate 1 — policy
|
||||||
const mentioned = userId != null && mentions.includes(userId);
|
const mentioned = userId != null && mentions.includes(userId);
|
||||||
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
|
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
|
||||||
if (!(membership === 'dm' || mentioned || repliedToMe)) continue;
|
if (!(alwaysNotify || mentioned || repliedToMe)) continue;
|
||||||
|
|
||||||
// gate 2 — presence
|
// gate 2 — presence
|
||||||
if (userId && this.presence.isViewing(userId, data.threadId)) continue;
|
if (userId && (await this.presence.isViewing(userId, data.threadId))) continue;
|
||||||
|
|
||||||
// gate 3 — mute
|
// gate 3 — mute
|
||||||
if (p.muted) continue;
|
if (p.muted) continue;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Presence seam: tracks which thread each socket has in the foreground so the notification
|
||||||
|
* projector can suppress push for a thread the recipient is actively viewing. Async so it can be
|
||||||
|
* backed by Redis across replicas (prod) or an in-memory Map on a single instance (dev).
|
||||||
|
*/
|
||||||
|
export interface PresencePort {
|
||||||
|
/** Record a socket's foregrounded thread (null when the window is blurred / no thread open). */
|
||||||
|
setFocus(socketId: string, userId: string, threadId: string | null): Promise<void>;
|
||||||
|
/** Forget everything about a socket (on disconnect). */
|
||||||
|
clearSocket(socketId: string): Promise<void>;
|
||||||
|
/** True if ANY of the user's sockets currently has this thread in the foreground. */
|
||||||
|
isViewing(userId: string, threadId: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESENCE_PORT = Symbol('PRESENCE_PORT');
|
||||||
@@ -2,23 +2,23 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { PresenceService } from './presence.service';
|
import { PresenceService } from './presence.service';
|
||||||
|
|
||||||
describe('PresenceService', () => {
|
describe('PresenceService', () => {
|
||||||
it('reports viewing only for the focused thread, and clears on disconnect', () => {
|
it('reports viewing only for the focused thread, and clears on disconnect', async () => {
|
||||||
const p = new PresenceService();
|
const p = new PresenceService();
|
||||||
expect(p.isViewing('alice', 'T1')).toBe(false);
|
expect(await p.isViewing('alice', 'T1')).toBe(false);
|
||||||
p.setFocus('sock1', 'alice', 'T1');
|
await p.setFocus('sock1', 'alice', 'T1');
|
||||||
expect(p.isViewing('alice', 'T1')).toBe(true);
|
expect(await p.isViewing('alice', 'T1')).toBe(true);
|
||||||
expect(p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
|
expect(await p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
|
||||||
p.setFocus('sock1', 'alice', 'T2'); // moved focus
|
await p.setFocus('sock1', 'alice', 'T2'); // moved focus
|
||||||
expect(p.isViewing('alice', 'T1')).toBe(false);
|
expect(await p.isViewing('alice', 'T1')).toBe(false);
|
||||||
expect(p.isViewing('alice', 'T2')).toBe(true);
|
expect(await p.isViewing('alice', 'T2')).toBe(true);
|
||||||
p.clearSocket('sock1');
|
await p.clearSocket('sock1');
|
||||||
expect(p.isViewing('alice', 'T2')).toBe(false);
|
expect(await p.isViewing('alice', 'T2')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('any of the actor’s sockets counts as viewing', () => {
|
it('any of the actor’s sockets counts as viewing', async () => {
|
||||||
const p = new PresenceService();
|
const p = new PresenceService();
|
||||||
p.setFocus('sockA', 'bob', 'T9');
|
await p.setFocus('sockA', 'bob', 'T9');
|
||||||
p.setFocus('sockB', 'bob', null); // a second tab, no focus
|
await p.setFocus('sockB', 'bob', null); // a second tab, no focus
|
||||||
expect(p.isViewing('bob', 'T9')).toBe(true);
|
expect(await p.isViewing('bob', 'T9')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { PresencePort } from './presence.port';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory focus tracker (single instance). Keyed on userId (the stable externalId the
|
* In-memory focus tracker (single instance). Keyed on userId (the stable externalId the
|
||||||
* gateway has as principal.userId). NOTE: room membership ≠ viewing — the sidebar joins
|
* gateway has as principal.userId). NOTE: room membership ≠ viewing — the sidebar joins
|
||||||
* every thread room for live updates, so presence uses an explicit `focus_thread` signal.
|
* every thread room for live updates, so presence uses an explicit `focus_thread` signal.
|
||||||
* Prod (multi-replica): back this with Redis.
|
* Multi-replica prod uses {@link RedisPresenceService} instead (wired when REDIS_URL is set).
|
||||||
|
*
|
||||||
|
* Methods are async to satisfy the PresencePort seam; the map is mutated synchronously, so an
|
||||||
|
* un-awaited setFocus is still visible to an immediately-following isViewing.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PresenceService {
|
export class PresenceService implements PresencePort {
|
||||||
private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus
|
private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus
|
||||||
|
|
||||||
setFocus(socketId: string, userId: string, threadId: string | null): void {
|
async setFocus(socketId: string, userId: string, threadId: string | null): Promise<void> {
|
||||||
this.focus.set(socketId, { userId, threadId });
|
this.focus.set(socketId, { userId, threadId });
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSocket(socketId: string): void {
|
async clearSocket(socketId: string): Promise<void> {
|
||||||
this.focus.delete(socketId);
|
this.focus.delete(socketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
isViewing(userId: string, threadId: string): boolean {
|
async isViewing(userId: string, threadId: string): Promise<boolean> {
|
||||||
for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true;
|
for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import type { PresencePort } from './presence.port';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis-backed presence for multi-replica prod: a socket connected to replica A and a message
|
||||||
|
* projected on replica B must share the same "who is viewing what" view, which an in-memory Map
|
||||||
|
* cannot provide. Wired (in place of {@link PresenceService}) only when REDIS_URL is set.
|
||||||
|
*
|
||||||
|
* Layout (per user, tiny):
|
||||||
|
* sock:{socketId} -> userId (so clearSocket can find the user), EX TTL
|
||||||
|
* user:{userId} -> HASH socketId=threadId, EX TTL
|
||||||
|
* isViewing = does the user hash hold this threadId for any socket. Keys carry a TTL so a crashed
|
||||||
|
* replica's entries self-heal; expiry errs toward "not viewing" (push is sent), the safe default.
|
||||||
|
*/
|
||||||
|
export class RedisPresenceService implements PresencePort, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RedisPresenceService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private static readonly TTL_SECONDS = 300;
|
||||||
|
private static readonly PREFIX = 'iios:presence';
|
||||||
|
|
||||||
|
constructor(url: string, client?: Redis) {
|
||||||
|
this.redis = client ?? new Redis(url, { maxRetriesPerRequest: null });
|
||||||
|
this.redis.on('error', (err) => this.logger.error(`redis presence error: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sockKey(socketId: string): string {
|
||||||
|
return `${RedisPresenceService.PREFIX}:sock:${socketId}`;
|
||||||
|
}
|
||||||
|
private userKey(userId: string): string {
|
||||||
|
return `${RedisPresenceService.PREFIX}:user:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFocus(socketId: string, userId: string, threadId: string | null): Promise<void> {
|
||||||
|
const ttl = RedisPresenceService.TTL_SECONDS;
|
||||||
|
const sock = this.sockKey(socketId);
|
||||||
|
const user = this.userKey(userId);
|
||||||
|
const pipe = this.redis.multi().set(sock, userId, 'EX', ttl);
|
||||||
|
if (threadId === null) {
|
||||||
|
// Blurred / no thread open — the socket stays connected but is viewing nothing.
|
||||||
|
pipe.hdel(user, socketId);
|
||||||
|
} else {
|
||||||
|
pipe.hset(user, socketId, threadId).expire(user, ttl);
|
||||||
|
}
|
||||||
|
await pipe.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearSocket(socketId: string): Promise<void> {
|
||||||
|
const sock = this.sockKey(socketId);
|
||||||
|
const userId = await this.redis.get(sock);
|
||||||
|
const pipe = this.redis.multi().del(sock);
|
||||||
|
if (userId) pipe.hdel(this.userKey(userId), socketId);
|
||||||
|
await pipe.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async isViewing(userId: string, threadId: string): Promise<boolean> {
|
||||||
|
const threads = await this.redis.hvals(this.userKey(userId));
|
||||||
|
return threads.includes(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.redis.quit().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,29 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
|||||||
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('the dm cap counts the whole batch, not one add at a time', async () => {
|
||||||
|
const add = (participantCount: number, targetCount: number) =>
|
||||||
|
opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount, targetCount });
|
||||||
|
expect((await add(1, 1)).allow).toBe(true); // 1 + 1 = 2, fine
|
||||||
|
const batch = await add(1, 2); // 1 + 2 = 3 — must be denied even though count is only 1
|
||||||
|
expect(batch.allow).toBe(false);
|
||||||
|
expect(batch.obligations[0]?.reason).toMatch(/two people/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listing a roster requires membership, except on a public channel', async () => {
|
||||||
|
const list = (extra: Record<string, unknown>) => opa.decide({ action: 'iios.thread.participant.list', ...extra });
|
||||||
|
// a private channel / group / dm: members only
|
||||||
|
const stranger = await list({ membership: 'channel', visibility: 'private', callerRole: undefined });
|
||||||
|
expect(stranger.allow).toBe(false);
|
||||||
|
expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
|
||||||
|
expect((await list({ membership: 'channel', visibility: 'private', callerRole: 'MEMBER' })).allow).toBe(true);
|
||||||
|
expect((await list({ membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
||||||
|
expect((await list({ membership: 'group', callerRole: undefined })).allow).toBe(false);
|
||||||
|
// a public channel's roster is open, and ungoverned threads are unchanged
|
||||||
|
expect((await list({ membership: 'channel', visibility: 'public', callerRole: undefined })).allow).toBe(true);
|
||||||
|
expect((await list({})).allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
|
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
|
||||||
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
|
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
|
||||||
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
|
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface OpaInput {
|
|||||||
membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel'
|
membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel'
|
||||||
visibility?: string; // 'public' | 'private' (channels)
|
visibility?: string; // 'public' | 'private' (channels)
|
||||||
participantCount?: number;
|
participantCount?: number;
|
||||||
|
targetCount?: number; // how many participants a single add call is inviting (1 for a single add)
|
||||||
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
||||||
alreadyMember?: boolean;
|
alreadyMember?: boolean;
|
||||||
isMember?: boolean;
|
isMember?: boolean;
|
||||||
@@ -38,11 +39,23 @@ export class DevOpaPort {
|
|||||||
switch (i.action) {
|
switch (i.action) {
|
||||||
case 'iios.thread.participant.add': {
|
case 'iios.thread.participant.add': {
|
||||||
const count = i.participantCount ?? 0;
|
const count = i.participantCount ?? 0;
|
||||||
if (i.membership === 'dm' && count >= 2) return deny('a direct message is limited to two people');
|
// `targetCount` is how many are being added in this call (1 for a single add), so the dm cap
|
||||||
|
// holds for a bulk import too instead of being evaluated one-at-a-time.
|
||||||
|
if (i.membership === 'dm' && count + (i.targetCount ?? 1) > 2) return deny('a direct message is limited to two people');
|
||||||
if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants');
|
if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants');
|
||||||
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||||
return allow();
|
return allow();
|
||||||
}
|
}
|
||||||
|
case 'iios.thread.participant.list': {
|
||||||
|
// Reading a thread's ROSTER. A public channel is open (it is discoverable anyway), but every
|
||||||
|
// other membership thread — dm, group, private channel — requires you to be a member, or any
|
||||||
|
// caller in the scope could enumerate a private channel's members by id. Ungoverned threads
|
||||||
|
// (no membership attribute, e.g. support) keep the previous open behaviour.
|
||||||
|
if (!i.membership) return allow();
|
||||||
|
if (i.membership === 'channel' && i.visibility === 'public') return allow();
|
||||||
|
if (i.callerRole === 'MEMBER' || i.callerRole === 'ADMIN') return allow();
|
||||||
|
return deny('you are not a member of this thread');
|
||||||
|
}
|
||||||
case 'iios.thread.participant.remove': {
|
case 'iios.thread.participant.remove': {
|
||||||
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
|
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
|
||||||
// (no membership attr) threads allow removal by any member.
|
// (no membership attr) threads allow removal by any member.
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ export class ThreadsController {
|
|||||||
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
|
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Governed bulk membership: add many users in one call (e.g. importing another channel's roster).
|
||||||
|
* Reports per-user outcome — `skipped` are already members — so a partial result is never silent.
|
||||||
|
*/
|
||||||
|
@Post(':id/participants/bulk')
|
||||||
|
@HttpCode(200)
|
||||||
|
async addParticipants(@Param('id') id: string, @Body() body: { userIds?: string[]; role?: string }, @Headers('authorization') auth?: string) {
|
||||||
|
if (!Array.isArray(body?.userIds)) throw new BadRequestException('userIds must be an array');
|
||||||
|
return this.messages.addParticipants(id, this.principal(auth), body.userIds, body.role);
|
||||||
|
}
|
||||||
|
|
||||||
/** Members of a thread with their role — drives the group settings member list. */
|
/** Members of a thread with their role — drives the group settings member list. */
|
||||||
@Get(':id/participants')
|
@Get(':id/participants')
|
||||||
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user