Compare commits
43 Commits
ade1d68015
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c9cd0c847b | |||
| d2820a64e3 | |||
| 272c6acd31 | |||
| eb0ace8ad7 | |||
| e2f66fe622 | |||
| 9a0c74cb6e | |||
| 0c8eaaf74b | |||
| ca02da000f | |||
| 2c61f49de1 | |||
| c946f1e061 | |||
| 3d08fa42f8 | |||
| 0336621c01 | |||
| 4f9a252118 | |||
| 1cbfddd4c2 | |||
| 90e37edf89 | |||
| b8bf1aa347 | |||
| 745a23823a | |||
| d1d4b56201 | |||
| b3eb027071 | |||
| f5c89159f4 | |||
| 8af25def83 | |||
| 23c43acf8f | |||
| e503ed8904 | |||
| 416cf59dc2 | |||
| aa73dee05d | |||
| 973b6a77eb | |||
| 3c5c6964e2 | |||
| 2753a8a367 | |||
| af45982176 | |||
| 0ee63c139f | |||
| 22eaf0f654 | |||
| 8878ee8c54 | |||
| ba264f401d | |||
| c4254749a4 | |||
| 4faf05fee9 | |||
| 9882177c59 | |||
| f16d7986c7 | |||
| ddd628ab6f | |||
| 2aa2893973 | |||
| 2f24a95ef4 | |||
| d02cd152de | |||
| fa93173b1f | |||
| 7a0fb2ca97 |
@@ -6,3 +6,30 @@ DATABASE_URL="postgresql://iios:iios@localhost:5434/iios?schema=public"
|
||||
JWT_SECRET="dev-only-change-me"
|
||||
# Per-app HS256 secrets, JSON map keyed by appId (the `session` platform port)
|
||||
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).
|
||||
# MEILI_KEY must equal the meilisearch server's MEILI_MASTER_KEY (docker-compose default below).
|
||||
MEILI_URL="http://localhost:7700"
|
||||
MEILI_KEY="dev-meili-master-key"
|
||||
# Consumed by docker-compose's meilisearch service; keep in sync with MEILI_KEY.
|
||||
MEILI_MASTER_KEY="dev-meili-master-key"
|
||||
|
||||
# BYO integration credentials at rest (Twilio SMS, SMTP): 32-byte base64 AES-256-GCM key.
|
||||
# Generate with: openssl rand -base64 32
|
||||
IIOS_CRED_KEY=""
|
||||
|
||||
# Orphaned-media garbage collection (uploaded-but-never-sent attachments).
|
||||
# Interval 0 = off; set e.g. 3600000 (hourly) in prod. Grace defaults to 24h.
|
||||
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"
|
||||
|
||||
@@ -29,5 +29,24 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Full-text message search. The service uses it when MEILI_URL is set (else search no-ops).
|
||||
# Point the service at it with MEILI_URL=http://localhost:7700 + MEILI_KEY=<master key>.
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.11
|
||||
container_name: iios-meili
|
||||
ports:
|
||||
- "7700:7700"
|
||||
environment:
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-dev-meili-master-key}
|
||||
volumes:
|
||||
- iios_meili_data:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:7700/health || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
iios_postgres_data:
|
||||
iios_meili_data:
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@insignia/iios-kernel-client",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -37,7 +37,11 @@ export interface OpenThreadResult {
|
||||
|
||||
export interface ReceiptEvent {
|
||||
interactionId: string;
|
||||
/** The reader's IIOS actor UUID — an internal id, NOT comparable to a caller's userId. */
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@insignia/iios-messaging-ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.14",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
@@ -18,6 +18,10 @@
|
||||
"types": "./dist/adapters/kernel-client.d.ts",
|
||||
"import": "./dist/adapters/kernel-client.js"
|
||||
},
|
||||
"./adapters/mock-inbox": {
|
||||
"types": "./dist/adapters/mock-inbox.d.ts",
|
||||
"import": "./dist/adapters/mock-inbox.js"
|
||||
},
|
||||
"./conformance": {
|
||||
"types": "./dist/conformance.d.ts",
|
||||
"import": "./dist/conformance.js"
|
||||
@@ -33,8 +37,9 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@insignia/iios-kernel-client": "*",
|
||||
"react": ">=18",
|
||||
"@insignia/iios-kernel-client": "*"
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@insignia/iios-kernel-client": {
|
||||
@@ -46,6 +51,7 @@
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Attachment,
|
||||
BulkAddResult,
|
||||
ChannelSummary,
|
||||
Conversation,
|
||||
CreateChannelInput,
|
||||
@@ -39,6 +40,15 @@ export interface MessagingAdapter {
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -61,6 +71,10 @@ export interface MessagingAdapter {
|
||||
* Absent => the composer offers no autocomplete (you can still type @text). */
|
||||
listMembers?(threadId: string): Promise<Person[]>;
|
||||
|
||||
/** The people you can start a conversation with (org directory). Drives the "New message"
|
||||
* people picker. Absent => the UI hides DM/group creation (you can still open channels). */
|
||||
directory?(): Promise<Person[]>;
|
||||
|
||||
// ── Channels (optional capability) ──────────────────────────────
|
||||
// A channel is just a third membership beyond dm/group: a discoverable, joinable room.
|
||||
// Implement all four to enable the channels UI; absent => the UI hides channels entirely.
|
||||
@@ -76,4 +90,22 @@ export interface MessagingAdapter {
|
||||
|
||||
/** Leave a channel by id. */
|
||||
leaveChannel?(threadId: string): Promise<void>;
|
||||
|
||||
// ── Group / channel administration (optional) ───────────────────
|
||||
// Enable the settings panel for groups + channels. Absent methods hide their affordance;
|
||||
// the server (OPA) still enforces who may actually add/remove/rename (admin-only).
|
||||
|
||||
/** Add a person to a group or private channel. */
|
||||
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. */
|
||||
removeMember?(threadId: string, userId: string): Promise<void>;
|
||||
|
||||
/** Rename a group or channel. */
|
||||
renameConversation?(threadId: string, subject: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { InboxAdapter } from '../inbox/adapter';
|
||||
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from '../inbox/types';
|
||||
|
||||
const PEOPLE: MailPerson[] = [
|
||||
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
|
||||
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
|
||||
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
|
||||
];
|
||||
|
||||
interface MockMail {
|
||||
subject: string;
|
||||
messages: MailMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory inbox for demos/tests. State is PER INSTANCE. Seeds a few work items (mention,
|
||||
* needs-reply, alert) plus mail threads that fold into the Open view as MAIL rows.
|
||||
*/
|
||||
export class MockInboxAdapter implements InboxAdapter {
|
||||
private seq = 100;
|
||||
private readonly items: InboxItem[];
|
||||
private readonly threads = new Map<string, MockMail>();
|
||||
/** threadIds that surface as standalone MAIL rows (in addition to work items). */
|
||||
private readonly mailFold = ['mt_welcome', 'mt_invoice'];
|
||||
|
||||
constructor(private readonly now: () => string = () => new Date().toISOString()) {
|
||||
const at = this.now();
|
||||
this.items = [
|
||||
{ id: 'in_1', kind: 'MENTION', state: 'OPEN', title: 'Sofia mentioned you', summary: '@you — can you confirm the Henderson scope?', priority: 'HIGH', threadId: 'mt_mention', createdAt: at },
|
||||
{ id: 'in_2', kind: 'NEEDS_REPLY', state: 'OPEN', title: 'Reply needed — Storm response', summary: 'Dan: crew rolling out at 7', priority: 'MEDIUM', threadId: 'mt_storm', createdAt: at },
|
||||
{ id: 'in_3', kind: 'SYSTEM_ALERT', state: 'OPEN', title: 'Ticket TK-204 updated', summary: 'Customer replied on the roof-leak case.', priority: 'LOW', createdAt: at },
|
||||
];
|
||||
this.threads.set('mt_mention', {
|
||||
subject: 'Henderson scope',
|
||||
messages: [{ id: 'm1', actorId: 'pp_sofia', kind: 'EMAIL', at, html: '<p>Can you confirm the <b>Henderson</b> scope by EOD?</p>', text: 'Can you confirm the Henderson scope by EOD?', attachment: null }],
|
||||
});
|
||||
this.threads.set('mt_storm', {
|
||||
subject: 'Storm response — East side',
|
||||
messages: [{ id: 'm2', actorId: 'pp_dan', kind: 'EMAIL', at, html: '<p>Crew is rolling out at 7. Confirm the Henderson job?</p>', text: 'Crew rolling out at 7. Confirm the Henderson job?', attachment: null }],
|
||||
});
|
||||
this.threads.set('mt_welcome', {
|
||||
subject: 'Welcome to the Founders Club',
|
||||
messages: [{ id: 'm3', actorId: 'system', kind: 'EMAIL', at, html: '<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>', text: 'Thanks for joining the Founders Club.', attachment: null }],
|
||||
});
|
||||
this.threads.set('mt_invoice', {
|
||||
subject: 'Invoice #1042 — Acme Roofing',
|
||||
messages: [{ id: 'm4', actorId: 'cust_acme', kind: 'EMAIL', at, html: '<p>Attached is invoice <b>#1042</b> for the East-side job.</p>', text: 'Attached is invoice #1042 for the East-side job.', attachment: null }],
|
||||
});
|
||||
}
|
||||
|
||||
async listInbox(state?: InboxState): Promise<InboxItem[]> {
|
||||
const showMail = !state || state === 'OPEN';
|
||||
const work = this.items.filter((i) => (state ? i.state === state : true));
|
||||
const mail: InboxItem[] = showMail
|
||||
? this.mailFold.map((tid) => {
|
||||
const t = this.threads.get(tid)!;
|
||||
const last = t.messages[t.messages.length - 1];
|
||||
return {
|
||||
id: `mail:${tid}`,
|
||||
kind: 'MAIL',
|
||||
state: 'OPEN' as InboxState,
|
||||
title: t.subject,
|
||||
...(last?.text ? { summary: last.text } : {}),
|
||||
priority: 'LOW',
|
||||
threadId: tid,
|
||||
createdAt: last?.at ?? this.now(),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return [...mail, ...work];
|
||||
}
|
||||
|
||||
async transition(id: string, state: InboxState): Promise<void> {
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (item) item.state = state;
|
||||
}
|
||||
|
||||
async mailHistory(threadId: string): Promise<MailMessage[]> {
|
||||
return [...(this.threads.get(threadId)?.messages ?? [])];
|
||||
}
|
||||
|
||||
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (t) t.messages = [...t.messages, { id: `r_${this.seq++}`, actorId: 'me', kind: 'MESSAGE', at: this.now(), html: null, text: content || null, attachment: attachment ?? null }];
|
||||
}
|
||||
|
||||
async uploadAttachment(file: File): Promise<MailAttachment> {
|
||||
return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name };
|
||||
}
|
||||
|
||||
async downloadAttachment(attachment: MailAttachment): Promise<string> {
|
||||
// Demo: no real bytes — hand back a data URL so the click resolves without a network call.
|
||||
return `data:${attachment.mimeType};base64,`;
|
||||
}
|
||||
|
||||
async directory(): Promise<MailPerson[]> {
|
||||
return [...PEOPLE];
|
||||
}
|
||||
|
||||
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
|
||||
const threadId = `mt_${this.seq++}`;
|
||||
this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `<p>${text}</p>`, text, attachment: attachments?.[0] ?? null }] });
|
||||
this.mailFold.unshift(threadId);
|
||||
void recipientUserId;
|
||||
}
|
||||
|
||||
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
|
||||
// A mock external send has no in-app thread — no-op beyond acknowledging.
|
||||
void target;
|
||||
void subject;
|
||||
void text;
|
||||
void attachments;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MessagingAdapter } from '../adapter';
|
||||
import type {
|
||||
Attachment,
|
||||
BulkAddResult,
|
||||
ChannelSummary,
|
||||
ChannelVisibility,
|
||||
Conversation,
|
||||
@@ -159,7 +160,51 @@ export class MockAdapter implements MessagingAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
async directory(): Promise<Person[]> {
|
||||
return MOCK_PEOPLE.map((p) => ({ ...p }));
|
||||
}
|
||||
|
||||
async addMember(threadId: string, userId: string): Promise<void> {
|
||||
const t = this.threads.get(threadId);
|
||||
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> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (t) t.participants = t.participants.filter((p) => p !== userId);
|
||||
}
|
||||
|
||||
async renameConversation(threadId: string, subject: string): Promise<void> {
|
||||
const t = this.threads.get(threadId);
|
||||
if (t) t.subject = subject;
|
||||
}
|
||||
|
||||
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
||||
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
|
||||
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
|
||||
const target = p.participantIds[0];
|
||||
for (const [id, t] of this.threads) {
|
||||
if (t.membership === 'dm' && t.participants.length === 2 && t.participants.includes(ME) && t.participants.includes(target)) {
|
||||
return { threadId: id };
|
||||
}
|
||||
}
|
||||
}
|
||||
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
|
||||
const threadId = `th_mock_${this.seq++}`;
|
||||
this.threads.set(threadId, {
|
||||
@@ -225,8 +270,9 @@ export class MockAdapter implements MessagingAdapter {
|
||||
// No-op: nobody is typing back in a mock.
|
||||
}
|
||||
|
||||
async markRead(): Promise<void> {
|
||||
// No-op: the mock has no second party to report a read.
|
||||
/** Echo a receipt the way a real server does, so the "ignore my own read" path is exercised. */
|
||||
async markRead(threadId: string, messageId: string): Promise<void> {
|
||||
this.emit(threadId, { kind: 'receipt', messageId, actorId: ME });
|
||||
}
|
||||
|
||||
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()));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import {
|
||||
SPECIAL_MENTIONS,
|
||||
insertMention,
|
||||
resolveMentions,
|
||||
trailingMentionQuery,
|
||||
} from '../mentions';
|
||||
import type { Attachment, Person, SendOpts } from '../types';
|
||||
|
||||
interface Suggestion {
|
||||
key: string;
|
||||
label: 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
|
||||
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
|
||||
*/
|
||||
export function Composer({
|
||||
members,
|
||||
canUpload,
|
||||
upload,
|
||||
onSend,
|
||||
onTyping,
|
||||
parentInteractionId,
|
||||
placeholder = 'Type a message… @ to mention, *bold*',
|
||||
}: {
|
||||
members: Person[];
|
||||
canUpload: boolean;
|
||||
upload: (file: File) => Promise<Attachment>;
|
||||
onSend: (text: string, opts?: SendOpts) => Promise<void>;
|
||||
onTyping?: () => void;
|
||||
parentInteractionId?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [staged, setStaged] = useState<Attachment | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingName, setUploadingName] = useState<string | null>(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 suggestions = useMemo<Suggestion[]>(() => {
|
||||
if (query === null) return [];
|
||||
const q = query.toLowerCase();
|
||||
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
|
||||
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
|
||||
return [...specials, ...people].slice(0, 6);
|
||||
}, [query, members]);
|
||||
const showSuggest = query !== null && suggestions.length > 0;
|
||||
|
||||
function pick(insert: string): void {
|
||||
setDraft((d) => insertMention(d, insert));
|
||||
}
|
||||
|
||||
async function onPickFile(e: ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadingName(file.name);
|
||||
try {
|
||||
setStaged(await upload(file));
|
||||
} catch {
|
||||
/* host surfaces upload errors */
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadingName(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e?: FormEvent): Promise<void> {
|
||||
e?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if ((!text && !staged) || sending) return;
|
||||
const mentions = resolveMentions(text, members);
|
||||
const att = staged;
|
||||
setDraft('');
|
||||
setStaged(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await onSend(text, {
|
||||
...(mentions.length ? { mentions } : {}),
|
||||
...(att ? { attachment: att } : {}),
|
||||
...(parentInteractionId ? { parentInteractionId } : {}),
|
||||
});
|
||||
} catch {
|
||||
setStaged(att);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
// 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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
{showSuggest ? (
|
||||
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.key}>
|
||||
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{uploading && uploadingName ? (
|
||||
<div className="miu-staged is-uploading">
|
||||
<span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…
|
||||
</div>
|
||||
) : staged ? (
|
||||
<div className="miu-staged">
|
||||
📎 {staged.name}
|
||||
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : 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">
|
||||
{canUpload ? (
|
||||
<>
|
||||
<input ref={fileRef} type="file" className="miu-file-input" onChange={onPickFile} aria-label="Attach a file" />
|
||||
<button type="button" className="miu-attach-btn" title="Attach a file" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? '…' : '📎'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="miu-input miu-composer-box"
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
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) => {
|
||||
setDraft(e.target.value);
|
||||
onTyping?.();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Conversation } from '../types';
|
||||
import { stripMarkup } from '../rich-text';
|
||||
|
||||
/** Initials for the avatar chip — first letters of the first two words. */
|
||||
function initials(title: string): string {
|
||||
@@ -37,7 +38,7 @@ export function ConversationList({
|
||||
</span>
|
||||
<span className="miu-convrow-main">
|
||||
<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>
|
||||
{c.unread > 0 ? (
|
||||
<span className="miu-badge" aria-label={`${c.unread} unread`}>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
import { ConversationSettings } from './conversation-settings';
|
||||
|
||||
function mount(adapter = new MockAdapter()) {
|
||||
render(
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
describe('MockAdapter member management', () => {
|
||||
it('adds and removes a member, and renames', async () => {
|
||||
const a = new MockAdapter();
|
||||
await a.addMember('th_mock_2', 'pp_sofia');
|
||||
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
|
||||
await a.removeMember('th_mock_2', 'pp_sofia');
|
||||
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(false);
|
||||
await a.renameConversation('th_mock_2', 'Renamed');
|
||||
expect((await a.listConversations()).find((c) => c.threadId === 'th_mock_2')?.title).toBe('Renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ConversationSettings />', () => {
|
||||
it('lists members and adds one from the directory', async () => {
|
||||
const a = mount();
|
||||
// A current member is shown.
|
||||
await screen.findByText('Dan Whitaker');
|
||||
// Add an addable person from the directory.
|
||||
const sofia = await screen.findByText('Sofia Ramirez');
|
||||
fireEvent.click(sofia);
|
||||
await waitFor(async () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import { useConversations } from '../hooks/use-conversations';
|
||||
import { ModalPortal } from './modal-portal';
|
||||
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
|
||||
* leave. Add/remove/rename appear only when the adapter implements them AND — for the server — OPA
|
||||
* allows it (admin-only); the panel is optimistic and surfaces the error if the door refuses.
|
||||
*/
|
||||
export function ConversationSettings({
|
||||
threadId,
|
||||
title,
|
||||
membership,
|
||||
onClose,
|
||||
onLeft,
|
||||
}: {
|
||||
threadId: string;
|
||||
title: string;
|
||||
membership: 'group' | 'channel';
|
||||
onClose: () => void;
|
||||
onLeft?: () => void;
|
||||
}) {
|
||||
const adapter = useAdapter();
|
||||
const [members, setMembers] = useState<Person[]>([]);
|
||||
const [directory, setDirectory] = useState<Person[]>([]);
|
||||
const [name, setName] = useState(title);
|
||||
const [q, setQ] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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 canRename = typeof adapter.renameConversation === '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(() => {
|
||||
let alive = true;
|
||||
void Promise.all([
|
||||
adapter.listMembers ? adapter.listMembers(threadId) : Promise.resolve<Person[]>([]),
|
||||
adapter.directory ? adapter.directory() : Promise.resolve<Person[]>([]),
|
||||
]).then(([m, d]) => {
|
||||
if (alive) {
|
||||
setMembers(m);
|
||||
setDirectory(d);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, threadId, nonce]);
|
||||
|
||||
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 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> {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
setNonce((n) => n + 1);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div className="miu-modal-overlay" onMouseDown={onClose}>
|
||||
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="miu-modal-head">
|
||||
<span>{membership === 'channel' ? 'Channel' : 'Group'} settings</span>
|
||||
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div className="miu-modal-body">
|
||||
{canRename ? (
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Name</span>
|
||||
<div className="miu-composer-row">
|
||||
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} aria-label="Conversation name" />
|
||||
<button
|
||||
type="button"
|
||||
className="miu-send"
|
||||
disabled={busy || !name.trim() || name.trim() === title}
|
||||
onClick={() => void run(() => adapter.renameConversation!(threadId, name.trim()))}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Members · {members.length}</span>
|
||||
<div className="miu-settings-list">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="miu-settings-member">
|
||||
<span className="miu-settings-name">{m.name}</span>
|
||||
<span className="miu-pill">{m.kind}</span>
|
||||
{canManage ? (
|
||||
<button type="button" className="miu-attach-x" title="Remove" disabled={busy} onClick={() => void run(() => adapter.removeMember!(threadId, m.id))}>
|
||||
✕
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<span className="miu-field-lbl">Add people</span>
|
||||
<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">
|
||||
{channelMode ? (
|
||||
<>
|
||||
{matchingChannels.length === 0 ? <div className="miu-empty">No channels to add from.</div> : null}
|
||||
{matchingChannels.slice(0, 25).map((c) => (
|
||||
<button key={c.threadId} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void stageImport(c)}>
|
||||
<span className="miu-settings-name">#{c.title}</span>
|
||||
<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>
|
||||
) : 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}
|
||||
</div>
|
||||
<div className="miu-modal-foot">
|
||||
{canLeave ? (
|
||||
<button type="button" className="miu-tab" disabled={busy} onClick={() => void run(async () => { await adapter.leaveChannel!(threadId); onLeft?.(); onClose(); })}>
|
||||
Leave {membership}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button type="button" className="miu-send" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { renderRichText } from '../rich-text';
|
||||
import { PopoverPortal } from './popover-portal';
|
||||
import type { Attachment } from '../types';
|
||||
import type { UiMessage } from '../hooks/use-messages';
|
||||
|
||||
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
||||
|
||||
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
||||
|
||||
/** Slack-style message time: today shows the clock, then "Yesterday", weekday, else a date. */
|
||||
function messageTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(+d)) return '';
|
||||
const now = new Date();
|
||||
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (d.toDateString() === now.toDateString()) return time;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return `Yesterday ${time}`;
|
||||
if (now.getTime() - d.getTime() < 7 * 86400000) return `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
|
||||
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
|
||||
}
|
||||
|
||||
function AttachmentView({ att }: { att: Attachment }) {
|
||||
if (isImage(att.mime)) {
|
||||
return (
|
||||
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-img-link">
|
||||
<img src={att.url} alt={att.name} className="miu-att-img" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-file">
|
||||
📎 {att.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick,
|
||||
* and — in the main thread only — a thread/reply affordance. */
|
||||
export function MessageItem({
|
||||
message,
|
||||
memberNames,
|
||||
canReact,
|
||||
onReact,
|
||||
seen,
|
||||
replyCount,
|
||||
onOpenThread,
|
||||
}: {
|
||||
message: UiMessage;
|
||||
memberNames: string[];
|
||||
canReact: boolean;
|
||||
onReact: (messageId: string, emoji: string) => void;
|
||||
seen: boolean;
|
||||
/** Present only in the main thread (not inside the pane). undefined => no thread affordance. */
|
||||
replyCount?: number;
|
||||
onOpenThread?: (messageId: string) => void;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const reactBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const m = message;
|
||||
|
||||
return (
|
||||
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
|
||||
<div className="miu-bubble-row">
|
||||
<div className="miu-bubble">
|
||||
{m.text ? renderRichText(m.text, memberNames) : null}
|
||||
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
||||
</div>
|
||||
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
|
||||
{messageTime(m.at)}
|
||||
</time>
|
||||
<div className="miu-msg-actions">
|
||||
{canReact ? (
|
||||
<div className="miu-react-wrap">
|
||||
<button ref={reactBtnRef} type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
|
||||
🙂
|
||||
</button>
|
||||
{pickerOpen ? (
|
||||
<PopoverPortal anchorRef={reactBtnRef} onClose={() => setPickerOpen(false)}>
|
||||
<div className="miu-react-picker">
|
||||
{REACTION_EMOJIS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
className="miu-react-emoji"
|
||||
onClick={() => {
|
||||
onReact(m.id, e);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPortal>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{onOpenThread ? (
|
||||
<button type="button" className="miu-react-btn" title="Reply in thread" onClick={() => onOpenThread(m.id)}>
|
||||
💬
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{m.reactions && m.reactions.length > 0 ? (
|
||||
<div className="miu-reactions">
|
||||
{m.reactions.map((r) => (
|
||||
<button key={r.emoji} type="button" className={`miu-reaction${r.mine ? ' is-mine' : ''}`} onClick={() => canReact && onReact(m.id, r.emoji)}>
|
||||
{r.emoji} {r.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{replyCount !== undefined && replyCount > 0 && onOpenThread ? (
|
||||
<button type="button" className="miu-thread-link" onClick={() => onOpenThread(m.id)}>
|
||||
💬 {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{message.mine && seen ? <span className="miu-seen">Seen</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,35 +3,86 @@ import { useConversations } from '../hooks/use-conversations';
|
||||
import { useAdapter } from '../provider';
|
||||
import { ConversationList } from './conversation-list';
|
||||
import { ChannelBrowser } from './channel-browser';
|
||||
import { NewConversation } from './new-conversation';
|
||||
import { Thread } from './thread';
|
||||
import { ThreadPane } from './thread-pane';
|
||||
|
||||
/**
|
||||
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
|
||||
* with a channel browser when the adapter supports channels. Owns only selection + browse state;
|
||||
* all data flows through the injected adapter via the hooks.
|
||||
*/
|
||||
export function Messenger() {
|
||||
export function Messenger({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||||
const { conversations, loading, error, refetch } = useConversations();
|
||||
const adapter = useAdapter();
|
||||
const channelsSupported = typeof adapter.browseChannels === 'function';
|
||||
const directorySupported = typeof adapter.directory === 'function';
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const [composing, setComposing] = useState(false); // "New message" people picker open
|
||||
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
|
||||
|
||||
useEffect(() => {
|
||||
if (browsing) return;
|
||||
if (browsing || composing) return;
|
||||
if (selected && conversations.some((c) => c.threadId === selected)) return;
|
||||
setSelected(conversations[0]?.threadId ?? null);
|
||||
}, [conversations, selected, browsing]);
|
||||
}, [conversations, selected, browsing, composing]);
|
||||
|
||||
// Switching conversations (or into browse/compose) closes any open thread pane.
|
||||
useEffect(() => setActiveRoot(null), [selected, browsing, composing]);
|
||||
|
||||
// Deep link (e.g. from global search): focus the requested conversation.
|
||||
useEffect(() => {
|
||||
if (!focusThreadId) return;
|
||||
setBrowsing(false);
|
||||
setComposing(false);
|
||||
setSelected(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 dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
||||
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
|
||||
|
||||
function pick(threadId: string): void {
|
||||
setBrowsing(false);
|
||||
setComposing(false);
|
||||
setSelected(threadId);
|
||||
}
|
||||
|
||||
function openBrowse(): void {
|
||||
setComposing(false);
|
||||
setBrowsing(true);
|
||||
}
|
||||
|
||||
function openCompose(): void {
|
||||
setBrowsing(false);
|
||||
setComposing(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-messenger">
|
||||
<aside className="miu-sidebar">
|
||||
@@ -42,12 +93,12 @@ export function Messenger() {
|
||||
<div className="miu-section">
|
||||
<div className="miu-section-head">
|
||||
<span>Channels</span>
|
||||
<button type="button" className="miu-section-add" title="Browse channels" onClick={() => setBrowsing(true)}>
|
||||
<button type="button" className="miu-section-add" title="Browse channels" onClick={openBrowse}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{channels.length > 0 ? (
|
||||
<ConversationList conversations={channels} selectedId={browsing ? null : selected} onSelect={pick} />
|
||||
<ConversationList conversations={channels} selectedId={browsing || composing ? null : selected} onSelect={pick} />
|
||||
) : (
|
||||
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
|
||||
)}
|
||||
@@ -55,12 +106,17 @@ export function Messenger() {
|
||||
) : null}
|
||||
|
||||
<div className="miu-section">
|
||||
{channelsSupported ? (
|
||||
{channelsSupported || directorySupported ? (
|
||||
<div className="miu-section-head">
|
||||
<span>Direct messages</span>
|
||||
{directorySupported ? (
|
||||
<button type="button" className="miu-section-add" title="New message" onClick={openCompose}>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ConversationList conversations={dms} selectedId={browsing ? null : selected} onSelect={pick} />
|
||||
<ConversationList conversations={dms} selectedId={browsing || composing ? null : selected} onSelect={pick} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -72,10 +128,30 @@ export function Messenger() {
|
||||
pick(threadId);
|
||||
}}
|
||||
/>
|
||||
) : composing ? (
|
||||
<NewConversation
|
||||
onCreated={(threadId) => {
|
||||
refetch();
|
||||
pick(threadId);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Thread threadId={selected} />
|
||||
<Thread
|
||||
threadId={selected}
|
||||
conversation={selectedConversation}
|
||||
activeRootId={activeRoot}
|
||||
onOpenThread={setActiveRoot}
|
||||
onLeft={() => {
|
||||
refetch();
|
||||
setSelected(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!browsing && !composing && selected && activeRoot ? (
|
||||
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, type ReactNode, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// The SDK theme tokens. A body-portaled node is outside the `.miu-messenger`/`.miu-inbox`
|
||||
// subtree that defines these, so we copy their resolved values onto the portal root.
|
||||
const THEME_VARS = [
|
||||
'--miu-bg', '--miu-panel', '--miu-panel-2', '--miu-border',
|
||||
'--miu-text', '--miu-muted', '--miu-accent', '--miu-accent-text', '--miu-radius',
|
||||
] as const;
|
||||
|
||||
export function copyThemeVars(): Record<string, string> {
|
||||
if (typeof document === 'undefined') return {};
|
||||
const src = document.querySelector('.miu-messenger, .miu-inbox');
|
||||
if (!src) return {};
|
||||
const cs = getComputedStyle(src);
|
||||
const out: Record<string, string> = {};
|
||||
for (const v of THEME_VARS) {
|
||||
const val = cs.getPropertyValue(v).trim();
|
||||
if (val) out[v] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an overlay into document.body so it escapes the host's stacking context and overflow.
|
||||
* A host wrapper like `.view { position: relative; z-index: 1 }` traps a `position: fixed` overlay
|
||||
* BELOW a sibling sticky header no matter how high its z-index — the only robust fix is to leave
|
||||
* that stacking context entirely. Theme tokens are copied from the live surface (captured once on
|
||||
* mount, synchronously, so there is no unstyled first paint) and re-applied on the portal root.
|
||||
*/
|
||||
export function ModalPortal({ children }: { children: ReactNode }) {
|
||||
const [vars] = useState<Record<string, string>>(copyThemeVars);
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(<div className="miu-portal" style={vars as CSSProperties}>{children}</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
import { Messenger } from './messenger';
|
||||
|
||||
function mount(adapter = new MockAdapter()) {
|
||||
render(
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<Messenger />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
describe('MockAdapter directory + openThread', () => {
|
||||
it('directory lists people you can message', async () => {
|
||||
const a = new MockAdapter();
|
||||
const people = await a.directory();
|
||||
expect(people.some((p) => p.name === 'Dan Whitaker')).toBe(true);
|
||||
});
|
||||
|
||||
it('one participant opens a dm; two+ open a group with a subject', async () => {
|
||||
const a = new MockAdapter();
|
||||
const dm = await a.openThread({ participantIds: ['pp_dan'], membership: 'dm' });
|
||||
const list = await a.listConversations();
|
||||
expect(list.find((c) => c.threadId === dm.threadId)?.membership).toBe('dm');
|
||||
|
||||
const group = await a.openThread({ participantIds: ['pp_dan', 'pp_priya'], membership: 'group', subject: 'Roof crew' });
|
||||
const g = (await a.listConversations()).find((c) => c.threadId === group.threadId);
|
||||
expect(g?.membership).toBe('group');
|
||||
expect(g?.title).toBe('Roof crew');
|
||||
});
|
||||
|
||||
it('opening a dm with the same person reuses the existing thread', async () => {
|
||||
const a = new MockAdapter();
|
||||
const first = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
|
||||
const second = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
|
||||
expect(second.threadId).toBe(first.threadId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<Messenger /> new conversation flow', () => {
|
||||
it('opens the picker from the Direct messages +, and starting a chat leaves the picker', async () => {
|
||||
mount();
|
||||
// Open the "New message" picker.
|
||||
fireEvent.click(await screen.findByTitle('New message'));
|
||||
expect(await screen.findByText('New message')).toBeTruthy();
|
||||
|
||||
// Pick a person and start a DM.
|
||||
fireEvent.click(await screen.findByText('Dan Whitaker'));
|
||||
fireEvent.click(screen.getByText('Start chat'));
|
||||
|
||||
// The picker closes (we're back in a thread view — the picker heading is gone).
|
||||
await waitFor(() => expect(screen.queryByText('Start chat')).toBeNull());
|
||||
});
|
||||
|
||||
it('selecting two people switches the action to group create', async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByTitle('New message'));
|
||||
fireEvent.click(await screen.findByText('Dan Whitaker'));
|
||||
fireEvent.click(await screen.findByText('Priya Nair'));
|
||||
expect(screen.getByText(/Create group \(2\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText('Group name')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import type { Person } from '../types';
|
||||
|
||||
/**
|
||||
* Start a direct message or a group — Slack-style. Pick people from the org directory: one selected
|
||||
* opens a DM (deduped by the adapter), two or more create a group with an optional name. Shown in
|
||||
* the main pane like the channel browser.
|
||||
*/
|
||||
export function NewConversation({ onCreated }: { onCreated?: (threadId: string) => void }) {
|
||||
const adapter = useAdapter();
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adapter.directory) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
adapter
|
||||
.directory()
|
||||
.then((p) => {
|
||||
if (alive) {
|
||||
setPeople(p);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter]);
|
||||
|
||||
const filtered = people.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
const isGroup = selected.length > 1;
|
||||
const canStart = selected.length >= 1 && !busy;
|
||||
|
||||
function toggle(id: string): void {
|
||||
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
if (!canStart) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await adapter.openThread({
|
||||
participantIds: selected,
|
||||
membership: isGroup ? 'group' : 'dm',
|
||||
...(isGroup && name.trim() ? { subject: name.trim() } : {}),
|
||||
});
|
||||
onCreated?.(res.threadId);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-browser">
|
||||
<div className="miu-browser-head">New message</div>
|
||||
<div className="miu-newconv">
|
||||
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
|
||||
{isGroup ? (
|
||||
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Group name (optional)" aria-label="Group name" />
|
||||
) : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
<div className="miu-browser-list">
|
||||
{loading && people.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{!loading && filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
|
||||
{filtered.map((p) => (
|
||||
<label key={p.id} className={`miu-person-row${selected.includes(p.id) ? ' is-active' : ''}`}>
|
||||
<input type="checkbox" checked={selected.includes(p.id)} onChange={() => toggle(p.id)} />
|
||||
<span className="miu-browser-name">{p.name}</span>
|
||||
<span className="miu-pill">{p.kind}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="miu-send" onClick={() => void start()} disabled={!canStart}>
|
||||
{busy ? 'Starting…' : isGroup ? `Create group (${selected.length})` : 'Start chat'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useLayoutEffect, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { copyThemeVars } from './modal-portal';
|
||||
|
||||
/**
|
||||
* A small popover (e.g. the reaction picker) rendered into document.body so it is never clipped by
|
||||
* a scroll container's `overflow: hidden` — the reported "emoji picker goes beneath the container"
|
||||
* bug. Positioned fixed just below the anchor, right-aligned to it, and re-placed on scroll/resize.
|
||||
* Closes on outside pointer-down, scroll of a different element, or Escape. Carries the SDK theme
|
||||
* tokens (copied from the live surface) since a body-portaled node is outside the themed subtree.
|
||||
*/
|
||||
export function PopoverPortal({
|
||||
anchorRef,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [vars] = useState(copyThemeVars);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
const place = (): void => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setPos({ top: r.bottom + 4, left: r.right });
|
||||
};
|
||||
place();
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: PointerEvent): void => {
|
||||
const target = e.target as Node;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
if ((target as Element).closest?.('.miu-popover')) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
if (typeof document === 'undefined' || !pos) return null;
|
||||
return createPortal(
|
||||
<div className="miu-portal">
|
||||
<div className="miu-popover" style={{ top: pos.top, left: pos.left, ...vars } as CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessagingProvider } from '../provider';
|
||||
import { Messenger } from './messenger';
|
||||
import { MockAdapter } from '../adapters/mock';
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<MessagingProvider adapter={new MockAdapter()}>
|
||||
<Messenger />
|
||||
</MessagingProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Slack-style thread pane', () => {
|
||||
it('opens a thread on 💬, posts a reply into it, and shows the reply count on the parent', async () => {
|
||||
mount();
|
||||
// Wait for the auto-selected thread's message to render WITH its actions (not just the sidebar
|
||||
// preview), then open the thread pane via the message's reply affordance (💬).
|
||||
const replyButtons = await screen.findAllByTitle('Reply in thread');
|
||||
fireEvent.click(replyButtons[0]!);
|
||||
expect(await screen.findByText('Thread')).toBeTruthy(); // pane header
|
||||
|
||||
// The pane's composer (placeholder "Reply…") — send a threaded reply.
|
||||
const replyInput = screen.getByPlaceholderText('Reply…') as HTMLInputElement;
|
||||
fireEvent.change(replyInput, { target: { value: 'on it' } });
|
||||
// The pane has its own Send; grab the last one (pane is rendered after the main composer).
|
||||
const sends = screen.getAllByText('Send');
|
||||
fireEvent.click(sends[sends.length - 1]!);
|
||||
|
||||
// The reply shows in the pane (replies are hidden from the main thread, so this is unique)...
|
||||
await waitFor(() => expect(screen.getByText('on it')).toBeTruthy());
|
||||
// ...and the parent now advertises the reply count as a thread-link button in the main thread.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /1 reply/ })).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMessages } from '../hooks/use-messages';
|
||||
import { useMembers } from '../hooks/use-members';
|
||||
import { Composer } from './composer';
|
||||
import { MessageItem } from './message-item';
|
||||
|
||||
/**
|
||||
* The Slack-style thread side panel: the root message + its replies + a composer that posts back
|
||||
* into the thread (parentInteractionId = root). Uses its own useMessages on the same thread; the
|
||||
* shared adapter subscription keeps it and the main view in sync.
|
||||
*/
|
||||
export function ThreadPane({
|
||||
threadId,
|
||||
rootId,
|
||||
onClose,
|
||||
}: {
|
||||
threadId: string;
|
||||
rootId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { messages, send, react, upload, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
||||
const members = useMembers(threadId);
|
||||
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||
|
||||
const root = useMemo(() => messages.find((m) => m.id === rootId), [messages, rootId]);
|
||||
const replies = useMemo(() => messages.filter((m) => m.parentInteractionId === rootId), [messages, rootId]);
|
||||
|
||||
return (
|
||||
<aside className="miu-pane">
|
||||
<header className="miu-pane-head">
|
||||
<span>Thread</span>
|
||||
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close thread">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="miu-messages miu-pane-messages">
|
||||
{root ? (
|
||||
<MessageItem message={root} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(root.id)} />
|
||||
) : (
|
||||
<div className="miu-empty">Message not found.</div>
|
||||
)}
|
||||
<div className="miu-pane-divider">{replies.length} {replies.length === 1 ? 'reply' : 'replies'}</div>
|
||||
{replies.map((m) => (
|
||||
<MessageItem key={m.id} message={m} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(m.id)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Composer
|
||||
members={members}
|
||||
canUpload={canUpload}
|
||||
upload={upload}
|
||||
onSend={send}
|
||||
onTyping={sendTyping}
|
||||
parentInteractionId={rootId}
|
||||
placeholder="Reply…"
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +1,94 @@
|
||||
import { useMemo, useState, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMessages } from '../hooks/use-messages';
|
||||
import { useMembers } from '../hooks/use-members';
|
||||
import {
|
||||
SPECIAL_MENTIONS,
|
||||
highlightMentions,
|
||||
insertMention,
|
||||
resolveMentions,
|
||||
trailingMentionQuery,
|
||||
} from '../mentions';
|
||||
|
||||
interface Suggestion {
|
||||
key: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
}
|
||||
import { Composer } from './composer';
|
||||
import { MessageItem } from './message-item';
|
||||
import { ConversationSettings } from './conversation-settings';
|
||||
import type { Conversation } from '../types';
|
||||
|
||||
/**
|
||||
* One conversation: message list + composer, driven by useMessages. Adds @mention autocomplete
|
||||
* (from the thread's members) and highlights mentions in message bodies. Transport-agnostic.
|
||||
* The main conversation view: top-level messages + composer. Replies (messages with a
|
||||
* parentInteractionId) are hidden here and live in the ThreadPane — a message with replies shows a
|
||||
* "N replies" link that opens it. @mentions, reactions, and attachments all work.
|
||||
*/
|
||||
export function Thread({ threadId }: { threadId: string | null }) {
|
||||
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId);
|
||||
export function Thread({
|
||||
threadId,
|
||||
conversation,
|
||||
activeRootId,
|
||||
onOpenThread,
|
||||
onLeft,
|
||||
}: {
|
||||
threadId: string | null;
|
||||
conversation?: Conversation | null;
|
||||
activeRootId?: string | null;
|
||||
onOpenThread?: (rootId: string) => void;
|
||||
onLeft?: () => void;
|
||||
}) {
|
||||
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
||||
const members = useMembers(threadId);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||
const query = trailingMentionQuery(draft);
|
||||
const suggestions = useMemo<Suggestion[]>(() => {
|
||||
if (query === null) return [];
|
||||
const q = query.toLowerCase();
|
||||
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
|
||||
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
|
||||
return [...specials, ...people].slice(0, 6);
|
||||
}, [query, members]);
|
||||
const showSuggest = query !== null && suggestions.length > 0;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
function pick(insert: string): void {
|
||||
setDraft((d) => insertMention(d, insert));
|
||||
}
|
||||
const membership = conversation?.membership;
|
||||
const manageable = membership === 'group' || membership === 'channel';
|
||||
|
||||
async function submit(e?: FormEvent): Promise<void> {
|
||||
e?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
const mentions = resolveMentions(text, members);
|
||||
setDraft('');
|
||||
setSending(true);
|
||||
try {
|
||||
await send(text, mentions.length ? { mentions } : undefined);
|
||||
} catch {
|
||||
// surfaced via the hook's error
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
||||
if (showSuggest && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pick(suggestions[0]!.insert);
|
||||
}
|
||||
}
|
||||
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
|
||||
const replyCount = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const m of messages) if (m.parentInteractionId) counts.set(m.parentInteractionId, (counts.get(m.parentInteractionId) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [messages]);
|
||||
|
||||
if (!threadId) {
|
||||
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-thread">
|
||||
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
|
||||
{conversation ? (
|
||||
<div className="miu-thread-head">
|
||||
<span className="miu-thread-title">
|
||||
{membership === 'channel' ? '# ' : ''}
|
||||
{conversation.title || 'Conversation'}
|
||||
</span>
|
||||
{manageable ? (
|
||||
<button type="button" className="miu-thread-settings" title="Settings" aria-label="Conversation settings" onClick={() => setSettingsOpen(true)}>
|
||||
⚙
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="miu-messages">
|
||||
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}>
|
||||
<div className="miu-bubble">{highlightMentions(m.text, memberNames)}</div>
|
||||
{m.reactions && m.reactions.length > 0 ? (
|
||||
<div className="miu-reactions">
|
||||
{m.reactions.map((r) => (
|
||||
<span key={r.emoji} className={`miu-reaction${r.mine ? ' is-mine' : ''}`}>
|
||||
{r.emoji} {r.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{m.mine && seenIds.has(m.id) ? <span className="miu-seen">Seen</span> : null}
|
||||
</div>
|
||||
{topLevel.map((m) => (
|
||||
<MessageItem
|
||||
key={m.id}
|
||||
message={m}
|
||||
memberNames={memberNames}
|
||||
canReact={canReact}
|
||||
onReact={(id, emoji) => void react(id, emoji)}
|
||||
seen={seenIds.has(m.id)}
|
||||
replyCount={replyCount.get(m.id) ?? 0}
|
||||
{...(onOpenThread ? { onOpenThread } : {})}
|
||||
/>
|
||||
))}
|
||||
{typingUserIds.length > 0 ? (
|
||||
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
{showSuggest ? (
|
||||
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.key}>
|
||||
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<input
|
||||
className="miu-input"
|
||||
value={draft}
|
||||
placeholder="Type a message… @ to mention"
|
||||
aria-label="Message"
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
sendTyping();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
|
||||
|
||||
{settingsOpen && manageable && conversation ? (
|
||||
<ConversationSettings
|
||||
threadId={conversation.threadId}
|
||||
title={conversation.title || ''}
|
||||
membership={membership as 'group' | 'channel'}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
{...(onLeft ? { onLeft } : {})}
|
||||
/>
|
||||
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import { isOwnMessage } from '../types';
|
||||
import type { Message, SendOpts } from '../types';
|
||||
import type { Attachment, Message, SendOpts } from '../types';
|
||||
|
||||
const TYPING_TTL_MS = 3500;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface MessagesState {
|
||||
error: string | null;
|
||||
send: (content: string, opts?: SendOpts) => Promise<void>;
|
||||
react: (messageId: string, emoji: string) => Promise<void>;
|
||||
upload: (file: File) => Promise<Attachment>;
|
||||
typingUserIds: string[];
|
||||
seenIds: Set<string>;
|
||||
sendTyping: () => void;
|
||||
@@ -147,17 +148,31 @@ export function useMessages(threadId: string | null): MessagesState {
|
||||
[adapter, threadId],
|
||||
);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File): Promise<Attachment> => {
|
||||
if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
|
||||
return adapter.upload(file);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
const sendTyping = useCallback(() => {
|
||||
if (threadId) adapter.sendTyping(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(() => {
|
||||
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;
|
||||
}, [raw]);
|
||||
}, [raw, currentActorId]);
|
||||
|
||||
// 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.
|
||||
@@ -196,6 +211,7 @@ export function useMessages(threadId: string | null): MessagesState {
|
||||
error,
|
||||
send,
|
||||
react,
|
||||
upload,
|
||||
typingUserIds,
|
||||
seenIds: seenMine,
|
||||
sendTyping,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MailAttachment, InboxItem, InboxState, MailMessage, MailPerson } from './types';
|
||||
|
||||
/**
|
||||
* The inbox seam. A host implements this; the SDK renders it. Mirrors MessagingAdapter's philosophy:
|
||||
* `listInbox` returns the UNIFIED feed (work items + mail folded in) so the folding logic lives in
|
||||
* one place (the adapter, which knows both sources). Compose methods are optional and degrade.
|
||||
*/
|
||||
export interface InboxAdapter {
|
||||
/** The unified inbox: work items + mail threads as rows, filtered by state (mail shows in OPEN). */
|
||||
listInbox(state?: InboxState): Promise<InboxItem[]>;
|
||||
|
||||
/** Transition a work item's state (Done/Snooze/Archive…). Mail rows are not transitioned. */
|
||||
transition(id: string, state: InboxState): Promise<void>;
|
||||
|
||||
/** Messages of one mail thread (HTML + text parts) for the reader. */
|
||||
mailHistory(threadId: string): Promise<MailMessage[]>;
|
||||
|
||||
/** Reply into an existing mail thread, optionally with one attachment. */
|
||||
mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void>;
|
||||
|
||||
// ── Attachments (optional) — absent hides the attach affordance ──
|
||||
/** Upload a file to storage, returning a reference to send with a reply/compose. */
|
||||
uploadAttachment?(file: File): Promise<MailAttachment>;
|
||||
|
||||
/** Resolve a short-lived URL to view/download an attachment. Absent => attachment chips are
|
||||
* shown but not clickable. */
|
||||
downloadAttachment?(attachment: MailAttachment): Promise<string>;
|
||||
|
||||
// ── Compose (optional) — absent hides the "New message" affordance ──
|
||||
/** People you can compose an in-app message to. */
|
||||
directory?(): Promise<MailPerson[]>;
|
||||
/** App-to-app mail (no SMTP) to a registered user's in-app inbox. */
|
||||
composeInternal?(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
|
||||
/** External email (SMTP) to an address. */
|
||||
composeExternal?(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useInboxAdapter } from './provider';
|
||||
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './types';
|
||||
|
||||
export interface InboxData {
|
||||
items: InboxItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
transition: (id: string, state: InboxState) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/** The unified inbox for a given filter state. Refetches when the filter changes. */
|
||||
export function useInbox(state?: InboxState): InboxData {
|
||||
const adapter = useInboxAdapter();
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
adapter
|
||||
.listInbox(state)
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setItems(list);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, state, nonce]);
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
||||
const transition = useCallback(
|
||||
async (id: string, next: InboxState) => {
|
||||
await adapter.transition(id, next);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return { items, loading, error, transition, refetch };
|
||||
}
|
||||
|
||||
export interface MailThreadState {
|
||||
messages: MailMessage[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
|
||||
canAttach: boolean;
|
||||
upload: (file: File) => Promise<MailAttachment>;
|
||||
canDownload: boolean;
|
||||
download: (attachment: MailAttachment) => Promise<string>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/** One mail thread: history + reply. */
|
||||
export function useMailThread(threadId: string | null): MailThreadState {
|
||||
const adapter = useInboxAdapter();
|
||||
const [messages, setMessages] = useState<MailMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
adapter
|
||||
.mailHistory(threadId)
|
||||
.then((m) => {
|
||||
if (!alive) return;
|
||||
setMessages(m);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, threadId, nonce]);
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
||||
const reply = useCallback(
|
||||
async (content: string, attachment?: MailAttachment) => {
|
||||
if (!threadId) return;
|
||||
await adapter.mailReply(threadId, content, attachment);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter, threadId],
|
||||
);
|
||||
const upload = useCallback(
|
||||
async (file: File) => {
|
||||
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
|
||||
return adapter.uploadAttachment(file);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
const download = useCallback(
|
||||
async (attachment: MailAttachment) => {
|
||||
if (!adapter.downloadAttachment) throw new Error('attachment download is not supported by this adapter');
|
||||
return adapter.downloadAttachment(attachment);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
reply,
|
||||
canAttach: typeof adapter.uploadAttachment === 'function',
|
||||
upload,
|
||||
canDownload: typeof adapter.downloadAttachment === 'function',
|
||||
download,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposeState {
|
||||
supported: boolean;
|
||||
directory: MailPerson[];
|
||||
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
|
||||
sendExternal: (target: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
|
||||
canAttach: boolean;
|
||||
upload: (file: File) => Promise<MailAttachment>;
|
||||
}
|
||||
|
||||
/** Compose a new message — in-app (to a person) or external (to an email). */
|
||||
export function useCompose(): ComposeState {
|
||||
const adapter = useInboxAdapter();
|
||||
const supported = typeof adapter.composeInternal === 'function';
|
||||
const [directory, setDirectory] = useState<MailPerson[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adapter.directory) return;
|
||||
let alive = true;
|
||||
adapter
|
||||
.directory()
|
||||
.then((d) => {
|
||||
if (alive) setDirectory(d);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setDirectory([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter]);
|
||||
|
||||
const sendInternal = useCallback(
|
||||
async (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => {
|
||||
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
|
||||
await adapter.composeInternal(recipientUserId, subject, text, attachments);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
const sendExternal = useCallback(
|
||||
async (target: string, subject: string, text: string, attachments?: MailAttachment[]) => {
|
||||
if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter');
|
||||
await adapter.composeExternal(target, subject, text, attachments);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
const upload = useCallback(
|
||||
async (file: File) => {
|
||||
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
|
||||
return adapter.uploadAttachment(file);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return { supported, directory, sendInternal, sendExternal, canAttach: typeof adapter.uploadAttachment === 'function', upload };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { InboxProvider } from './provider';
|
||||
import { Inbox } from './inbox';
|
||||
import { MockInboxAdapter } from '../adapters/mock-inbox';
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<InboxProvider adapter={new MockInboxAdapter()}>
|
||||
<Inbox />
|
||||
</InboxProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('mock inbox adapter', () => {
|
||||
it('unifies work items + mail in the Open view; other states drop mail', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
const open = await a.listInbox('OPEN');
|
||||
expect(open.some((i) => i.kind === 'MAIL')).toBe(true);
|
||||
expect(open.some((i) => i.kind === 'MENTION')).toBe(true);
|
||||
const done = await a.listInbox('DONE');
|
||||
expect(done.some((i) => i.kind === 'MAIL')).toBe(false);
|
||||
});
|
||||
|
||||
it('transition moves a work item out of Open', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
await a.transition('in_3', 'DONE');
|
||||
expect((await a.listInbox('OPEN')).some((i) => i.id === 'in_3')).toBe(false);
|
||||
expect((await a.listInbox('DONE')).some((i) => i.id === 'in_3')).toBe(true);
|
||||
});
|
||||
|
||||
it('mail reply appends to the thread', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
await a.mailReply('mt_welcome', 'thanks!');
|
||||
expect((await a.mailHistory('mt_welcome')).some((m) => m.text === 'thanks!')).toBe(true);
|
||||
});
|
||||
|
||||
it('reply carries an uploaded attachment', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
const ref = await a.uploadAttachment(new File(['x'], 'plan.pdf', { type: 'application/pdf' }));
|
||||
expect(ref).toMatchObject({ filename: 'plan.pdf', mimeType: 'application/pdf' });
|
||||
await a.mailReply('mt_welcome', '', ref);
|
||||
const last = (await a.mailHistory('mt_welcome')).at(-1)!;
|
||||
expect(last.attachment?.filename).toBe('plan.pdf');
|
||||
});
|
||||
|
||||
it('composeInternal carries a first attachment onto the new thread', async () => {
|
||||
const a = new MockInboxAdapter();
|
||||
const ref = await a.uploadAttachment(new File(['x'], 'quote.png', { type: 'image/png' }));
|
||||
await a.composeInternal('pp_sofia', 'Quote', 'see attached', [ref]);
|
||||
const open = await a.listInbox('OPEN');
|
||||
const row = open.find((i) => i.title === 'Quote')!;
|
||||
expect((await a.mailHistory(row.threadId!)).at(-1)?.attachment?.filename).toBe('quote.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<Inbox /> (rendered)', () => {
|
||||
it('lists items and opens a mail thread on click', async () => {
|
||||
mount();
|
||||
// A folded mail row is present.
|
||||
const welcome = await screen.findByText('Welcome to the Founders Club');
|
||||
fireEvent.click(welcome);
|
||||
// The reader opens with a reply box.
|
||||
expect(await screen.findByLabelText('Reply')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('replies into a mail thread', async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
|
||||
const input = (await screen.findByLabelText('Reply')) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'got it' } });
|
||||
fireEvent.click(screen.getByText('Reply'));
|
||||
await waitFor(() => expect(screen.getByText('got it')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('attaches a file into a mail reply', async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
|
||||
const file = new File(['data'], 'roof.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(await screen.findByLabelText('Attach file'), { target: { files: [file] } });
|
||||
// The pending chip shows the file, then Reply sends it.
|
||||
await screen.findByText(/roof\.pdf/);
|
||||
fireEvent.click(screen.getByText('Reply'));
|
||||
await waitFor(() => expect(screen.getAllByText(/roof\.pdf/).length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('composes an in-app message and it shows in the inbox', async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText('New message'));
|
||||
// pick a recipient
|
||||
fireEvent.click(await screen.findByText('Sofia Ramirez'));
|
||||
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Quick q' } });
|
||||
fireEvent.change(screen.getByLabelText('Message body'), { target: { value: 'ping' } });
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(screen.getByText('Quick q')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { ModalPortal } from '../components/modal-portal';
|
||||
import { useCompose, useInbox, useMailThread } from './hooks';
|
||||
import type { InboxItem, InboxState, MailAttachment, MailPerson } from './types';
|
||||
|
||||
const fmtBytes = (n: number): string => {
|
||||
if (!n) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const FILTERS: { value: InboxState; label: string }[] = [
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'SNOOZED', label: 'Snoozed' },
|
||||
{ value: 'DONE', label: 'Done' },
|
||||
{ value: 'ARCHIVED', label: 'Archived' },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
MAIL: 'Mail',
|
||||
MENTION: 'Mention',
|
||||
NEEDS_REPLY: 'Needs reply',
|
||||
SYSTEM_ALERT: 'Alert',
|
||||
SUPPORT_UPDATE: 'Support',
|
||||
};
|
||||
|
||||
const timeOf = (iso?: string): string => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(+d) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */
|
||||
export function Inbox({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||||
const [filter, setFilter] = useState<InboxState>('OPEN');
|
||||
const { items, loading, error, transition, refetch } = useInbox(filter);
|
||||
const compose = useCompose();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [composing, setComposing] = useState(false);
|
||||
|
||||
// Deep link (e.g. from global search): mail lives in the Open view, so switch there and let the
|
||||
// selection effect pick the matching thread once the list loads.
|
||||
useEffect(() => {
|
||||
if (focusThreadId) setFilter('OPEN');
|
||||
}, [focusThreadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusThreadId) {
|
||||
const hit = items.find((i) => i.threadId === focusThreadId);
|
||||
if (hit) {
|
||||
setSelectedId(hit.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (selectedId && items.some((i) => i.id === selectedId)) return;
|
||||
setSelectedId(items[0]?.id ?? null);
|
||||
}, [items, selectedId, focusThreadId]);
|
||||
|
||||
const selected = items.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="miu-inbox">
|
||||
<div className="miu-inbox-bar">
|
||||
<div className="miu-inbox-filters">
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.value} type="button" className={`miu-tab${filter === f.value ? ' is-active' : ''}`} onClick={() => setFilter(f.value)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{compose.supported ? (
|
||||
<button type="button" className="miu-send" onClick={() => setComposing(true)}>
|
||||
New message
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="miu-inbox-body">
|
||||
<aside className="miu-inbox-list">
|
||||
{loading && items.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{!loading && items.length === 0 ? <div className="miu-empty">Nothing here — you're all caught up 🎉</div> : null}
|
||||
{items.map((it) => (
|
||||
<button key={it.id} type="button" className={`miu-inbox-row${it.id === selectedId ? ' is-active' : ''}`} onClick={() => setSelectedId(it.id)}>
|
||||
<span className="miu-inbox-glyph" aria-hidden="true">{it.kind === 'MAIL' ? '✉' : it.kind === 'MENTION' ? '@' : '•'}</span>
|
||||
<span className="miu-inbox-main">
|
||||
<span className="miu-inbox-row-top">
|
||||
<span className="miu-pill">{KIND_LABEL[it.kind] ?? it.kind}</span>
|
||||
<span className="miu-inbox-title">{it.title}</span>
|
||||
</span>
|
||||
{it.summary ? <span className="miu-inbox-summary">{it.summary}</span> : null}
|
||||
</span>
|
||||
{it.state !== 'OPEN' ? <span className="miu-pill">{it.state.toLowerCase()}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<section className="miu-inbox-detail">
|
||||
{selected ? <Detail item={selected} onTransition={(s) => void transition(selected.id, s)} /> : <div className="miu-empty miu-thread-empty">Select an item to read.</div>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{composing ? <ComposeModal onClose={() => setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) {
|
||||
return (
|
||||
<div className="miu-detail">
|
||||
{item.state === 'OPEN' && item.kind !== 'MAIL' ? (
|
||||
<div className="miu-detail-actions">
|
||||
<button type="button" className="miu-tab" onClick={() => onTransition('SNOOZED')}>Snooze</button>
|
||||
<button type="button" className="miu-tab" onClick={() => onTransition('DONE')}>Done</button>
|
||||
<button type="button" className="miu-tab" onClick={() => onTransition('ARCHIVED')}>Archive</button>
|
||||
</div>
|
||||
) : null}
|
||||
{item.threadId ? (
|
||||
<MailReader threadId={item.threadId} subject={item.title} />
|
||||
) : (
|
||||
<div className="miu-detail-body">
|
||||
<div className="miu-detail-title">{item.title}</div>
|
||||
{item.summary ? <div className="miu-detail-summary">{item.summary}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
|
||||
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
|
||||
const { messages, loading, error, reply, canAttach, upload, canDownload, download } = useMailThread(threadId);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [pending, setPending] = useState<MailAttachment | null>(null);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||
const [attachErr, setAttachErr] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function openAttachment(att: MailAttachment): Promise<void> {
|
||||
try {
|
||||
const url = await download(att);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch (err) {
|
||||
setAttachErr(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
setUploadingName(file.name);
|
||||
setAttachErr(null);
|
||||
try {
|
||||
setPending(await upload(file));
|
||||
} catch (err) {
|
||||
setAttachErr(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
setUploadingName(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const text = draft.trim();
|
||||
if ((!text && !pending) || sending) return;
|
||||
const att = pending;
|
||||
setDraft('');
|
||||
setPending(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await reply(text, att ?? undefined);
|
||||
} catch {
|
||||
setDraft(text);
|
||||
setPending(att);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-mail">
|
||||
<header className="miu-mail-head">{subject || '(no subject)'}</header>
|
||||
<div className="miu-mail-body">
|
||||
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{messages.map((m) => (
|
||||
<article key={m.id} className="miu-mail-msg">
|
||||
<div className="miu-mail-meta">
|
||||
<span>{m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''}</span>
|
||||
<span>{timeOf(m.at)}</span>
|
||||
</div>
|
||||
{m.html ? (
|
||||
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
|
||||
) : m.text ? (
|
||||
<div className="miu-mail-text">{m.text}</div>
|
||||
) : null}
|
||||
{m.attachment ? (
|
||||
canDownload ? (
|
||||
<button type="button" className="miu-attach-chip miu-attach-dl" onClick={() => void openAttachment(m.attachment!)} title="Download">
|
||||
📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''} ↓
|
||||
</button>
|
||||
) : (
|
||||
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
|
||||
)
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
|
||||
{attaching && uploadingName ? (
|
||||
<div className="miu-attach-pending">
|
||||
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…</span>
|
||||
</div>
|
||||
) : pending ? (
|
||||
<div className="miu-attach-pending">
|
||||
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
|
||||
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment">✕</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="miu-composer-row">
|
||||
{canAttach ? (
|
||||
<>
|
||||
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
|
||||
<button type="button" className="miu-attach-btn" onClick={() => fileRef.current?.click()} disabled={attaching || !!pending} title="Attach a file" aria-label="Attach a file">
|
||||
{attaching ? '…' : '📎'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<input className="miu-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply…" aria-label="Reply" />
|
||||
<button type="submit" className="miu-send" disabled={(!draft.trim() && !pending) || sending}>Reply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
|
||||
const compose = useCompose();
|
||||
const [mode, setMode] = useState<'internal' | 'external'>('internal');
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
const canSend = !!recipient && !!subject.trim() && (!!body.trim() || attachments.length > 0) && !busy && !attaching;
|
||||
|
||||
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || attachments.length >= 10) return;
|
||||
setAttaching(true);
|
||||
setUploadingName(file.name);
|
||||
setErr(null);
|
||||
try {
|
||||
const ref = await compose.upload(file);
|
||||
setAttachments((a) => [...a, ref]);
|
||||
} catch (e2) {
|
||||
setErr(e2 instanceof Error ? e2.message : String(e2));
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
setUploadingName(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function send(): Promise<void> {
|
||||
if (!canSend) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
const atts = attachments.length > 0 ? attachments : undefined;
|
||||
try {
|
||||
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim(), atts);
|
||||
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), atts);
|
||||
onSent();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div className="miu-modal-overlay" onMouseDown={onClose}>
|
||||
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="miu-modal-head">
|
||||
<span>New message</span>
|
||||
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div className="miu-modal-body">
|
||||
<div className="miu-compose-modes">
|
||||
<button type="button" className={`miu-tab${mode === 'internal' ? ' is-active' : ''}`} onClick={() => { setMode('internal'); setRecipient(''); }}>In-app</button>
|
||||
<button type="button" className={`miu-tab${mode === 'external' ? ' is-active' : ''}`} onClick={() => { setMode('external'); setRecipient(''); }}>Email</button>
|
||||
</div>
|
||||
|
||||
{mode === 'internal' ? (
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">To (person)</span>
|
||||
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
|
||||
<div className="miu-people">
|
||||
{filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
|
||||
{filtered.map((p: MailPerson) => (
|
||||
<label key={p.id} className={`miu-person${recipient === p.id ? ' is-active' : ''}`}>
|
||||
<input type="radio" name="miu-recipient" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
|
||||
<span>{p.name}</span>
|
||||
<span className="miu-pill">{p.kind}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">To (email)</span>
|
||||
<input className="miu-input" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" aria-label="To email" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Subject</span>
|
||||
<input className="miu-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" aria-label="Subject" />
|
||||
</div>
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Message</span>
|
||||
<textarea className="miu-input miu-textarea" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={5} aria-label="Message body" />
|
||||
</div>
|
||||
{compose.canAttach ? (
|
||||
<div className="miu-field">
|
||||
<span className="miu-field-lbl">Attachments</span>
|
||||
<div className="miu-attach-list">
|
||||
{attachments.map((a, i) => (
|
||||
<span key={`${a.contentRef}-${i}`} className="miu-attach-pending">
|
||||
<span className="miu-attach-chip">📎 {a.filename ?? 'attachment'}{a.sizeBytes ? ` · ${fmtBytes(a.sizeBytes)}` : ''}</span>
|
||||
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment">✕</button>
|
||||
</span>
|
||||
))}
|
||||
{attaching && uploadingName ? (
|
||||
<span className="miu-attach-pending">
|
||||
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…</span>
|
||||
</span>
|
||||
) : null}
|
||||
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
|
||||
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
|
||||
📎 Attach
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{err ? <div className="miu-empty miu-error">{err}</div> : null}
|
||||
</div>
|
||||
<div className="miu-modal-foot">
|
||||
<button type="button" className="miu-tab" onClick={onClose}>Cancel</button>
|
||||
<button type="button" className="miu-send" onClick={() => void send()} disabled={!canSend}>{busy ? 'Sending…' : 'Send'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { InboxAdapter } from './adapter';
|
||||
|
||||
const InboxContext = createContext<InboxAdapter | null>(null);
|
||||
|
||||
export function InboxProvider({ adapter, children }: { adapter: InboxAdapter; children: ReactNode }) {
|
||||
return <InboxContext.Provider value={adapter}>{children}</InboxContext.Provider>;
|
||||
}
|
||||
|
||||
/** Access the host-injected inbox adapter. Throws outside a provider — a missing provider is a
|
||||
* wiring bug, and failing loudly beats a confusing null-deref three layers down. */
|
||||
export function useInboxAdapter(): InboxAdapter {
|
||||
const adapter = useContext(InboxContext);
|
||||
if (!adapter) throw new Error('useInboxAdapter must be used within an <InboxProvider>');
|
||||
return adapter;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Domain types for the inbox surface (work items + mail). Zero transport imports.
|
||||
|
||||
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
|
||||
|
||||
/** A unified inbox row — a work item (mention/needs-reply/alert) OR a mail thread. */
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
kind: string; // MAIL | MENTION | NEEDS_REPLY | SYSTEM_ALERT | …
|
||||
state: InboxState;
|
||||
title: string;
|
||||
summary?: string;
|
||||
priority: string;
|
||||
/** Present when the row opens a conversation/mail thread. */
|
||||
threadId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A mail attachment reference (bytes live in storage; the reader resolves a display URL). */
|
||||
export interface MailAttachment {
|
||||
contentRef: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
filename: string | null;
|
||||
}
|
||||
|
||||
/** One message in a mail thread — HTML and/or plain text, optionally an attachment. */
|
||||
export interface MailMessage {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
kind: string;
|
||||
at: string;
|
||||
html: string | null;
|
||||
text: string | null;
|
||||
attachment: MailAttachment | null;
|
||||
}
|
||||
|
||||
/** A person you can compose an in-app message to. */
|
||||
export interface MailPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'staff' | 'customer';
|
||||
}
|
||||
@@ -9,12 +9,22 @@ export { useMessages } from './hooks/use-messages';
|
||||
export { useChannels } from './hooks/use-channels';
|
||||
export { useMembers } from './hooks/use-members';
|
||||
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).
|
||||
export { Messenger } from './components/messenger';
|
||||
export { ConversationList } from './components/conversation-list';
|
||||
export { ChannelBrowser } from './components/channel-browser';
|
||||
export { Thread } from './components/thread';
|
||||
export { ThreadPane } from './components/thread-pane';
|
||||
|
||||
// ── Inbox domain (work items + mail) ──
|
||||
export { InboxProvider, useInboxAdapter } from './inbox/provider';
|
||||
export { useInbox, useMailThread, useCompose } from './inbox/hooks';
|
||||
export { Inbox, MailReader } from './inbox/inbox';
|
||||
export type { InboxAdapter } from './inbox/adapter';
|
||||
export type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './inbox/types';
|
||||
|
||||
export type { MessagingAdapter } from './adapter';
|
||||
export type { ConversationsState } from './hooks/use-conversations';
|
||||
@@ -22,6 +32,7 @@ export type { MessagesState, UiMessage } from './hooks/use-messages';
|
||||
export type { ChannelsState } from './hooks/use-channels';
|
||||
export type {
|
||||
Attachment,
|
||||
BulkAddResult,
|
||||
ChannelSummary,
|
||||
ChannelVisibility,
|
||||
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-text: #1a1206;
|
||||
--miu-radius: 12px;
|
||||
/* Collapsed composer height — textarea, attach and send all use this so they never diverge. */
|
||||
--miu-composer-h: 38px;
|
||||
|
||||
display: flex;
|
||||
height: 100%;
|
||||
@@ -149,6 +151,155 @@
|
||||
.miu-msg.is-pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.miu-bubble-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.miu-msg.is-mine .miu-bubble-row {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.miu-msg-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.miu-msg-time {
|
||||
font-size: 11px;
|
||||
color: var(--miu-muted);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* thread header + group/channel settings */
|
||||
.miu-thread-head {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-thread-title {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.miu-thread-settings {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--miu-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.miu-thread-settings:hover {
|
||||
background: var(--miu-panel-2);
|
||||
color: var(--miu-text);
|
||||
}
|
||||
.miu-settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.miu-settings-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: 10px;
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.miu-settings-member.is-add {
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-settings-member.is-add:hover {
|
||||
border-color: var(--miu-accent);
|
||||
}
|
||||
.miu-settings-name {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.miu-settings-plus {
|
||||
color: var(--miu-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-thread-link {
|
||||
align-self: flex-start;
|
||||
margin-top: 3px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-msg.is-mine .miu-thread-link {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.miu-react-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.miu-react-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
padding: 2px;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.miu-msg:hover .miu-react-btn {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.miu-react-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Portaled to <body> via .miu-popover so it's never clipped by a scroll container. */
|
||||
.miu-popover {
|
||||
position: fixed;
|
||||
z-index: 2147483000;
|
||||
transform: translateX(-100%); /* right-align the picker's right edge to the anchor */
|
||||
}
|
||||
.miu-react-picker {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
width: max-content;
|
||||
}
|
||||
/* On my own (right-aligned) messages, anchor the picker to the left instead so it stays in view. */
|
||||
.miu-react-emoji {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.miu-react-emoji:hover {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-reactions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -160,10 +311,35 @@
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel-2);
|
||||
color: var(--miu-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-reaction.is-mine {
|
||||
border-color: var(--miu-accent);
|
||||
}
|
||||
|
||||
/* attachments */
|
||||
.miu-att-img-link {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.miu-att-img {
|
||||
max-width: 260px;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-att-file {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel-2);
|
||||
color: var(--miu-text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.miu-seen {
|
||||
font-size: 11px;
|
||||
color: var(--miu-muted);
|
||||
@@ -189,10 +365,118 @@
|
||||
.miu-composer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-composer-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
/* Pin the controls to the bottom so a growing textarea never stretches them (WhatsApp). */
|
||||
align-items: flex-end;
|
||||
}
|
||||
.miu-file-input {
|
||||
display: none;
|
||||
}
|
||||
.miu-attach-btn {
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
width: var(--miu-composer-h);
|
||||
height: var(--miu-composer-h);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-text);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.miu-staged {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.miu-staged-x {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--miu-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* inbox mail attachments */
|
||||
.miu-attach-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel-2);
|
||||
color: var(--miu-text);
|
||||
font-size: 12.5px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.miu-mail-msg .miu-attach-chip {
|
||||
margin: 8px 12px 12px;
|
||||
}
|
||||
button.miu-attach-dl {
|
||||
cursor: pointer;
|
||||
color: var(--miu-text);
|
||||
}
|
||||
button.miu-attach-dl:hover {
|
||||
border-color: var(--miu-accent);
|
||||
color: var(--miu-accent);
|
||||
}
|
||||
.miu-attach-pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-attach-chip.is-uploading,
|
||||
.miu-staged.is-uploading {
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--miu-border);
|
||||
border-top-color: var(--miu-accent);
|
||||
border-radius: 50%;
|
||||
animation: miu-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes miu-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.miu-spinner { animation-duration: 2s; }
|
||||
}
|
||||
.miu-attach-x {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--miu-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.miu-attach-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.miu-suggest {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
@@ -237,6 +521,78 @@
|
||||
.miu-input:focus {
|
||||
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 {
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
@@ -246,6 +602,11 @@
|
||||
color: var(--miu-accent-text);
|
||||
background: var(--miu-accent);
|
||||
}
|
||||
.miu-composer .miu-send {
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
height: var(--miu-composer-h);
|
||||
}
|
||||
.miu-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -318,6 +679,38 @@
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* new conversation (DM / group) picker */
|
||||
.miu-newconv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
/* Submit buttons in the stacked create/compose forms size to content, not full width. */
|
||||
.miu-newconv .miu-send,
|
||||
.miu-channel-create .miu-send {
|
||||
align-self: flex-start;
|
||||
height: 38px;
|
||||
}
|
||||
.miu-person-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: 10px;
|
||||
background: var(--miu-panel);
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-person-row:hover {
|
||||
border-color: var(--miu-accent);
|
||||
}
|
||||
.miu-person-row.is-active {
|
||||
border-color: var(--miu-accent);
|
||||
background: color-mix(in srgb, var(--miu-accent) 10%, var(--miu-panel));
|
||||
}
|
||||
.miu-person-row .miu-browser-name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.miu-browser-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -377,6 +770,346 @@
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
|
||||
/* thread pane (Slack-style) */
|
||||
.miu-pane {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
}
|
||||
.miu-pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-pane-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--miu-muted);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
}
|
||||
.miu-pane-messages {
|
||||
background: var(--miu-bg);
|
||||
}
|
||||
.miu-pane-divider {
|
||||
font-size: 11.5px;
|
||||
color: var(--miu-muted);
|
||||
text-align: center;
|
||||
margin: 4px 0;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
}
|
||||
|
||||
/* ── Inbox ── */
|
||||
.miu-inbox {
|
||||
--miu-bg: #0e0e13;
|
||||
--miu-panel: #16161d;
|
||||
--miu-panel-2: #1d1d26;
|
||||
--miu-border: #262631;
|
||||
--miu-text: #e9e9ef;
|
||||
--miu-muted: #9a9aa7;
|
||||
--miu-accent: #fda913;
|
||||
--miu-accent-text: #1a1206;
|
||||
--miu-radius: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
color: var(--miu-text);
|
||||
background: var(--miu-bg);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
.miu-inbox-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-inbox-filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.miu-tab {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-tab.is-active {
|
||||
background: var(--miu-accent);
|
||||
color: var(--miu-accent-text);
|
||||
border-color: var(--miu-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-inbox-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.miu-inbox-list {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
}
|
||||
.miu-inbox-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.miu-inbox-row:hover,
|
||||
.miu-inbox-row.is-active {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-inbox-glyph {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--miu-muted);
|
||||
background: var(--miu-panel-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-inbox-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.miu-inbox-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
color: var(--miu-muted);
|
||||
background: var(--miu-panel-2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.miu-inbox-title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.miu-inbox-summary {
|
||||
color: var(--miu-muted);
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.miu-inbox-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--miu-bg);
|
||||
}
|
||||
.miu-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.miu-detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-detail-body {
|
||||
padding: 22px;
|
||||
}
|
||||
.miu-detail-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.miu-detail-summary {
|
||||
color: var(--miu-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* mail reader */
|
||||
.miu-mail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.miu-mail-head {
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.miu-mail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.miu-mail-msg {
|
||||
flex: 0 0 auto; /* don't let the flex column shrink cards — they'd clip their own content */
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: var(--miu-radius);
|
||||
background: var(--miu-panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.miu-mail-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
font-size: 12px;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-mail-frame {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
.miu-mail-text {
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* compose modal — portaled to <body>, so carry SDK theme-token defaults here (the host's
|
||||
own token mapping + a synchronous copy on the portal root override these). */
|
||||
.miu-portal {
|
||||
--miu-bg: #0e0e13;
|
||||
--miu-panel: #16161d;
|
||||
--miu-panel-2: #1d1d26;
|
||||
--miu-border: #262631;
|
||||
--miu-text: #e9e9ef;
|
||||
--miu-muted: #9a9aa7;
|
||||
--miu-accent: #fda913;
|
||||
--miu-accent-text: #1a1206;
|
||||
--miu-radius: 12px;
|
||||
}
|
||||
.miu-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483000;
|
||||
background: rgba(2, 2, 6, 0.6);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.miu-modal {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: 16px;
|
||||
background: var(--miu-panel);
|
||||
color: var(--miu-text);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.miu-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--miu-border);
|
||||
font-weight: 700;
|
||||
}
|
||||
.miu-modal-body {
|
||||
padding: 16px 18px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.miu-modal-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--miu-border);
|
||||
}
|
||||
.miu-compose-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.miu-field-lbl {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--miu-muted);
|
||||
}
|
||||
.miu-textarea {
|
||||
resize: vertical;
|
||||
min-height: 90px;
|
||||
}
|
||||
.miu-people {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.miu-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.miu-person.is-active {
|
||||
background: var(--miu-panel-2);
|
||||
}
|
||||
.miu-person span:nth-of-type(1) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* misc */
|
||||
.miu-empty {
|
||||
padding: 24px;
|
||||
|
||||
@@ -24,6 +24,16 @@ export interface Conversation {
|
||||
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. */
|
||||
export interface ChannelSummary {
|
||||
threadId: string;
|
||||
@@ -48,9 +58,13 @@ export interface Reaction {
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
/** A resolved URL for display/download. May be empty until resolved by the host. */
|
||||
url: string;
|
||||
mime: string;
|
||||
name: string;
|
||||
/** Storage reference — carried so send() can persist the message part (upload returns it). */
|
||||
contentRef?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
@@ -6,10 +6,10 @@ export default defineConfig({
|
||||
// entry, never reachable from `index`, because it imports vitest, and bundling
|
||||
// that into the main barrel would drag a test runner into every consumer's
|
||||
// production build.
|
||||
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
|
||||
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
|
||||
format: ['esm'],
|
||||
// Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file).
|
||||
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
|
||||
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
|
||||
clean: true,
|
||||
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Meilisearch for IIOS message search (prod).
|
||||
#
|
||||
# IIOS deploys to Kubernetes via ArgoCD from the platform-engineering/k8s-pods repo
|
||||
# (services/iios/). This file is the template for the prod Meilisearch instance — copy it into
|
||||
# k8s-pods/services/iios/meilisearch.yaml and let ArgoCD sync it. Then wire the service:
|
||||
#
|
||||
# 1. Create the master key once (32+ random chars) and set it in the Secret below (or via a
|
||||
# sealed-secret / your secret manager — do NOT commit a real key in plaintext):
|
||||
# kubectl -n <iios-namespace> create secret generic iios-meili \
|
||||
# --from-literal=MEILI_MASTER_KEY="$(openssl rand -base64 32)"
|
||||
# 2. Add these env vars to the iios-service Deployment (services/iios/deployment.yaml):
|
||||
# - name: MEILI_URL
|
||||
# value: http://meilisearch:7700
|
||||
# - name: MEILI_KEY
|
||||
# valueFrom: { secretKeyRef: { name: iios-meili, key: MEILI_MASTER_KEY } }
|
||||
# 3. After the first deploy, backfill existing messages once: POST /v1/search/reindex per tenant
|
||||
# (authenticated as a scope member) — new messages index automatically on message.sent.
|
||||
#
|
||||
# Set the namespace on each object (or via kustomize) to match the iios-service namespace so the
|
||||
# `meilisearch` Service DNS resolves as http://meilisearch:7700 from the pod.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: iios-meili
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Replace with a real key (or manage out-of-band per note #1 and delete this stringData block).
|
||||
MEILI_MASTER_KEY: "CHANGE_ME_meili_master_key"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: iios-meili-data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: meilisearch
|
||||
labels: { app: meilisearch }
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: { type: Recreate } # single RWO volume — never run two writers
|
||||
selector:
|
||||
matchLabels: { app: meilisearch }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: meilisearch }
|
||||
spec:
|
||||
containers:
|
||||
- name: meilisearch
|
||||
image: getmeili/meilisearch:v1.11
|
||||
ports:
|
||||
- containerPort: 7700
|
||||
env:
|
||||
- name: MEILI_ENV
|
||||
value: "production"
|
||||
- name: MEILI_NO_ANALYTICS
|
||||
value: "true"
|
||||
- name: MEILI_MASTER_KEY
|
||||
valueFrom:
|
||||
secretKeyRef: { name: iios-meili, key: MEILI_MASTER_KEY }
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /meili_data
|
||||
resources:
|
||||
requests: { cpu: "100m", memory: "256Mi" }
|
||||
limits: { cpu: "1", memory: "1Gi" }
|
||||
readinessProbe:
|
||||
httpGet: { path: /health, port: 7700 }
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet: { path: /health, port: 7700 }
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: iios-meili-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: meilisearch
|
||||
labels: { app: meilisearch }
|
||||
spec:
|
||||
selector: { app: meilisearch }
|
||||
ports:
|
||||
- port: 7700
|
||||
targetPort: 7700
|
||||
# ClusterIP (internal only) — reached by iios-service as http://meilisearch:7700.
|
||||
type: ClusterIP
|
||||
@@ -29,6 +29,7 @@
|
||||
"ioredis": "^5.11.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^4.1.0",
|
||||
"meilisearch": "^0.45.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"prisma": "^6.2.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "IiosProviderCredential" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scopeId" TEXT NOT NULL,
|
||||
"providerType" TEXT NOT NULL,
|
||||
"cipherText" TEXT NOT NULL,
|
||||
"iv" TEXT NOT NULL,
|
||||
"authTag" TEXT NOT NULL,
|
||||
"keyVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
"displayHints" JSONB,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "IiosProviderCredential_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IiosProviderCredential_scopeId_idx" ON "IiosProviderCredential"("scopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "IiosProviderCredential_scopeId_providerType_key" ON "IiosProviderCredential"("scopeId", "providerType");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IiosProviderCredential" ADD CONSTRAINT "IiosProviderCredential_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "IiosClientRegistry" ALTER COLUMN "allowedAppIds" DROP DEFAULT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IiosMediaObject" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scopeId" TEXT NOT NULL,
|
||||
"objectKey" TEXT NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"sizeBytes" BIGINT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "IiosMediaObject_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "IiosMediaObject_objectKey_key" ON "IiosMediaObject"("objectKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IiosMediaObject_createdAt_idx" ON "IiosMediaObject"("createdAt");
|
||||
@@ -317,10 +317,33 @@ model IiosScope {
|
||||
callbacks IiosCallbackRequest[]
|
||||
notificationSubscriptions IiosNotificationSubscription[]
|
||||
messageTemplates IiosMessageTemplate[]
|
||||
providerCredentials IiosProviderCredential[]
|
||||
|
||||
@@index([orgId, appId, tenantId])
|
||||
}
|
||||
|
||||
/// A tenant's own credentials for an egress provider (BYO: Twilio SMS, own SMTP, …).
|
||||
/// The secret is sealed with AES-256-GCM under the platform key (IIOS_CRED_KEY); only
|
||||
/// `displayHints` (non-secret, e.g. from-number / SID last-4) is ever read back out.
|
||||
/// One row per (scope, providerType). Resolved at send time by the channel's provider.
|
||||
model IiosProviderCredential {
|
||||
id String @id @default(cuid())
|
||||
scopeId String
|
||||
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||
providerType String // TWILIO_SMS | SMTP | WHATSAPP_CLOUD | …
|
||||
cipherText String // base64(AES-256-GCM ciphertext of the JSON config)
|
||||
iv String // base64 nonce
|
||||
authTag String // base64 GCM auth tag
|
||||
keyVersion Int @default(1)
|
||||
displayHints Json? // non-secret display fields (fromNumber, sidLast4, …)
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scopeId, providerType])
|
||||
@@index([scopeId])
|
||||
}
|
||||
|
||||
/// A channel-specific handle (phone/email/portal user). NOT identity — stays
|
||||
/// UNVERIFIED until MDM resolves it; no silent merge.
|
||||
model IiosSourceHandle {
|
||||
@@ -520,6 +543,22 @@ model IiosMessagePart {
|
||||
@@unique([interactionId, partIndex])
|
||||
}
|
||||
|
||||
/// A stored media object (bytes behind the StoragePort). A row is recorded when bytes actually
|
||||
/// land (MediaService.put), so an orphan sweep can reap objects that no message part references —
|
||||
/// e.g. a file attached in a composer but never sent. "Referenced" is derived at sweep time from
|
||||
/// IiosMessagePart.contentRef (no stored flag to drift), after a grace period so in-progress
|
||||
/// composes are never reaped.
|
||||
model IiosMediaObject {
|
||||
id String @id @default(cuid())
|
||||
scopeId String
|
||||
objectKey String @unique
|
||||
mime String
|
||||
sizeBytes BigInt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// Transactional outbox — written in the same tx as the business row.
|
||||
model IiosOutboxEvent {
|
||||
eventId String @id @default(cuid())
|
||||
|
||||
@@ -10,6 +10,7 @@ import { OutboxModule } from './outbox/outbox.module';
|
||||
import { ThreadsModule } from './threads/threads.module';
|
||||
import { MessageModule } from './messaging/message.module';
|
||||
import { InboxModule } from './inbox/inbox.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { TemplateModule } from './templates/template.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { MediaModule } from './media/media.module';
|
||||
@@ -39,6 +40,7 @@ import { DevController } from './dev/dev.controller';
|
||||
ThreadsModule,
|
||||
MessageModule,
|
||||
InboxModule,
|
||||
SearchModule,
|
||||
TemplateModule,
|
||||
MailModule,
|
||||
MediaModule,
|
||||
|
||||
@@ -2,15 +2,20 @@ import { Module } from '@nestjs/common';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { CapabilityProviderRegistry } from './capability.registry';
|
||||
import { CapabilityBroker } from './capability.broker';
|
||||
import { ProviderCredentialService } from './provider-credential.service';
|
||||
import { ProviderCredentialController } from './provider-credential.controller';
|
||||
|
||||
/**
|
||||
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the
|
||||
* outbound layer routes all sends through policy + obligations + a pluggable
|
||||
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
||||
* Also owns per-scope BYO provider credentials (Twilio SMS, …) — sealed at rest and
|
||||
* resolved by the channel providers at send time.
|
||||
*/
|
||||
@Module({
|
||||
imports: [MediaModule],
|
||||
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
||||
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
||||
controllers: [ProviderCredentialController],
|
||||
providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService],
|
||||
exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService],
|
||||
})
|
||||
export class CapabilityModule {}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { CapabilityProvider } from '@insignia/iios-contracts';
|
||||
import { SandboxProvider } from './sandbox.provider';
|
||||
import { HttpProvider } from './http.provider';
|
||||
import { EmailProvider } from './email.provider';
|
||||
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
|
||||
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromConfig, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
|
||||
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
|
||||
import { credKeyFromEnv } from './secret-crypto';
|
||||
import { ProviderCredentialService } from './provider-credential.service';
|
||||
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
|
||||
|
||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||
@@ -21,7 +24,10 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||
export class CapabilityProviderRegistry {
|
||||
private readonly byChannel = new Map<string, CapabilityProvider>();
|
||||
|
||||
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
|
||||
constructor(
|
||||
@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort,
|
||||
@Optional() private readonly credentials?: ProviderCredentialService,
|
||||
) {
|
||||
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
||||
for (const ch of DEFAULT_CHANNELS) {
|
||||
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
||||
@@ -30,11 +36,21 @@ export class CapabilityProviderRegistry {
|
||||
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
||||
}
|
||||
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments
|
||||
// resolve through the media StoragePort when one is bound (else attachments FAIL closed).
|
||||
// resolve through the media StoragePort when one is bound (else attachments FAIL closed). A
|
||||
// tenant's own SMTP (BYO) is resolved per scope at send time and takes precedence over the env
|
||||
// identity; register the provider whenever EITHER the env SMTP or the credential store exists.
|
||||
const smtp = smtpIdentityFromEnv();
|
||||
if (smtp) {
|
||||
const smtpCreds = credKeyFromEnv() && this.credentials ? this.credentials : undefined;
|
||||
if (smtp || smtpCreds) {
|
||||
const resolver = this.storage ? storageResolver(this.storage) : undefined;
|
||||
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
|
||||
const credResolver = smtpCreds ? (scopeId: string) => smtpCreds.resolve(scopeId, 'SMTP').then(smtpIdentityFromConfig) : undefined;
|
||||
this.register(new SmtpProvider(smtp ?? undefined, smtpFallbackFromEnv() ?? undefined, undefined, resolver, credResolver));
|
||||
}
|
||||
// BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered
|
||||
// only when the platform key + credential store are present — else SMS stays on the sandbox.
|
||||
if (credKeyFromEnv() && this.credentials) {
|
||||
const creds = this.credentials;
|
||||
this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise<TwilioCreds | null>));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from '@nestjs/common';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||
import { ProviderCredentialService } from './provider-credential.service';
|
||||
|
||||
const SUPPORTED = new Set(['TWILIO_SMS', 'SMTP']);
|
||||
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
|
||||
const isEmail = (v: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v);
|
||||
|
||||
/** Validate + shape the credential config for a provider type. Throws 400 on bad input.
|
||||
* be-crm does the strict client-facing validation; this is IIOS's own guard. */
|
||||
function buildConfig(providerType: string, body: Record<string, unknown>): Record<string, unknown> {
|
||||
if (providerType === 'TWILIO_SMS') {
|
||||
const accountSid = str(body.accountSid);
|
||||
const authToken = str(body.authToken);
|
||||
const fromNumber = str(body.fromNumber);
|
||||
if (!accountSid || !authToken || !fromNumber) throw new BadRequestException('accountSid, authToken and fromNumber are required');
|
||||
return { accountSid, authToken, fromNumber };
|
||||
}
|
||||
if (providerType === 'SMTP') {
|
||||
const host = str(body.host);
|
||||
const user = str(body.user);
|
||||
const pass = str(body.pass);
|
||||
const fromEmail = str(body.fromEmail);
|
||||
const fromName = str(body.fromName);
|
||||
if (!host || !user || !pass || !fromEmail) throw new BadRequestException('host, user, pass and fromEmail are required');
|
||||
if (!isEmail(fromEmail)) throw new BadRequestException('fromEmail must be a valid email');
|
||||
return { host, port: Number(body.port) || 587, secure: body.secure === true || body.secure === 'true', user, pass, fromEmail, ...(fromName ? { fromName } : {}) };
|
||||
}
|
||||
throw new BadRequestException(`unsupported providerType: ${providerType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scope BYO integration credentials. The caller's attested session decides the scope, so a
|
||||
* tenant can only read/write ITS OWN provider credentials. The secret is sealed by the service;
|
||||
* GET returns a masked status (never the token). be-crm gates this behind tenant-admin policy.
|
||||
*/
|
||||
@Controller('v1/providers')
|
||||
export class ProviderCredentialController {
|
||||
constructor(
|
||||
private readonly session: SessionVerifier,
|
||||
private readonly actors: ActorResolver,
|
||||
private readonly credentials: ProviderCredentialService,
|
||||
) {}
|
||||
|
||||
@Put(':providerType/credentials')
|
||||
async put(
|
||||
@Param('providerType') providerType: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
this.assertSupported(providerType);
|
||||
const config = buildConfig(providerType, body ?? {});
|
||||
const scope = await this.actors.resolveScope(this.principal(authorization));
|
||||
await this.credentials.upsert(scope.id, providerType, config);
|
||||
return this.credentials.status(scope.id, providerType);
|
||||
}
|
||||
|
||||
@Get(':providerType/credentials')
|
||||
async get(@Param('providerType') providerType: string, @Headers('authorization') authorization?: string) {
|
||||
this.assertSupported(providerType);
|
||||
const scope = await this.actors.resolveScope(this.principal(authorization));
|
||||
return this.credentials.status(scope.id, providerType);
|
||||
}
|
||||
|
||||
private assertSupported(providerType: string): void {
|
||||
if (!SUPPORTED.has(providerType)) throw new BadRequestException(`unsupported providerType: ${providerType}`);
|
||||
}
|
||||
|
||||
private principal(authorization?: string): MessagePrincipal {
|
||||
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
||||
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||
return this.session.verify(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ProviderCredentialService } from './provider-credential.service';
|
||||
|
||||
const KEY_B64 = Buffer.alloc(32, 7).toString('base64');
|
||||
|
||||
/** Minimal in-memory stand-in for prisma.iiosProviderCredential (upsert/findUnique). */
|
||||
function fakePrisma() {
|
||||
const rows = new Map<string, Record<string, unknown>>();
|
||||
const k = (scopeId: string, providerType: string) => `${scopeId}::${providerType}`;
|
||||
return {
|
||||
_rows: rows,
|
||||
iiosProviderCredential: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async upsert({ where, create, update }: any) {
|
||||
const key = k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType);
|
||||
const existing = rows.get(key);
|
||||
const row = existing ? { ...existing, ...update } : { ...create };
|
||||
rows.set(key, row);
|
||||
return row;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async findUnique({ where }: any) {
|
||||
return rows.get(k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType)) ?? null;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function make(prisma: ReturnType<typeof fakePrisma>, env: NodeJS.ProcessEnv): ProviderCredentialService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = new ProviderCredentialService(prisma as any);
|
||||
s.env = env;
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('ProviderCredentialService', () => {
|
||||
let prisma: ReturnType<typeof fakePrisma>;
|
||||
let svc: ProviderCredentialService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = fakePrisma();
|
||||
svc = make(prisma, { IIOS_CRED_KEY: KEY_B64 } as NodeJS.ProcessEnv);
|
||||
});
|
||||
|
||||
it('seals the secret at rest — plaintext token never stored', async () => {
|
||||
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' });
|
||||
const stored = JSON.stringify([...prisma._rows.values()]);
|
||||
expect(stored).not.toContain('tok_secret');
|
||||
expect(stored).toContain('cipherText');
|
||||
});
|
||||
|
||||
it('resolves back the exact config it sealed', async () => {
|
||||
const cfg = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
|
||||
await svc.upsert('scope_1', 'TWILIO_SMS', cfg);
|
||||
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toEqual(cfg);
|
||||
});
|
||||
|
||||
it('status is masked — reveals hints, never the token', async () => {
|
||||
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC1234567', authToken: 'tok_secret', fromNumber: '+15550001111' });
|
||||
const status = await svc.status('scope_1', 'TWILIO_SMS');
|
||||
expect(status).toMatchObject({ configured: true, enabled: true, hints: { fromNumber: '+15550001111', sidLast4: '4567' } });
|
||||
expect(JSON.stringify(status)).not.toContain('tok_secret');
|
||||
});
|
||||
|
||||
it('reports not-configured for an unknown scope', async () => {
|
||||
expect(await svc.status('nope', 'TWILIO_SMS')).toEqual({ configured: false });
|
||||
expect(await svc.resolve('nope', 'TWILIO_SMS')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resolve a disabled credential', async () => {
|
||||
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 't', fromNumber: '+1' }, { enabled: false });
|
||||
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toBeNull();
|
||||
});
|
||||
|
||||
it('throws when the platform key is absent (fail closed, never store plaintext)', async () => {
|
||||
const noKey = make(prisma, {} as NodeJS.ProcessEnv);
|
||||
await expect(noKey.upsert('s', 'TWILIO_SMS', { accountSid: 'A', authToken: 't', fromNumber: '+1' })).rejects.toThrow(/IIOS_CRED_KEY/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { credKeyFromEnv, sealSecret, openSecret, type SealedSecret } from './secret-crypto';
|
||||
|
||||
/** A provider config is an opaque JSON bag; each provider knows its own shape. */
|
||||
export type ProviderConfig = Record<string, unknown>;
|
||||
|
||||
export interface CredentialStatus {
|
||||
configured: boolean;
|
||||
enabled?: boolean;
|
||||
hints?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Non-secret display fields surfaced by `status` — NEVER the secret itself. */
|
||||
function hintsFor(providerType: string, config: ProviderConfig): Record<string, unknown> {
|
||||
if (providerType === 'TWILIO_SMS') {
|
||||
const sid = String(config.accountSid ?? '');
|
||||
return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) };
|
||||
}
|
||||
if (providerType === 'SMTP') {
|
||||
return { host: config.host ?? null, port: config.port ?? null, user: config.user ?? null, fromEmail: config.fromEmail ?? null, fromName: config.fromName ?? null };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tenant's BYO integration credentials, one row per (scope, providerType). The secret is
|
||||
* sealed with AES-256-GCM under the platform key before it touches the DB; only non-secret
|
||||
* `displayHints` are ever read back. `resolve` decrypts on demand at send time; `status` never
|
||||
* returns the secret. Fail-closed: no platform key ⇒ upsert throws (we refuse to store plaintext).
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProviderCredentialService {
|
||||
/** Overridable in tests; defaults to the process env (holds IIOS_CRED_KEY). */
|
||||
env: NodeJS.ProcessEnv = process.env;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async upsert(scopeId: string, providerType: string, config: ProviderConfig, opts?: { enabled?: boolean }): Promise<void> {
|
||||
const key = credKeyFromEnv(this.env);
|
||||
if (!key) throw new BadRequestException('IIOS_CRED_KEY is not configured — cannot store integration credentials');
|
||||
const sealed = sealSecret(JSON.stringify(config), key);
|
||||
const enabled = opts?.enabled ?? true;
|
||||
const displayHints = hintsFor(providerType, config) as Prisma.InputJsonValue;
|
||||
await this.prisma.iiosProviderCredential.upsert({
|
||||
where: { scopeId_providerType: { scopeId, providerType } },
|
||||
create: { scopeId, providerType, ...sealed, displayHints, enabled },
|
||||
update: { ...sealed, displayHints, enabled },
|
||||
});
|
||||
}
|
||||
|
||||
/** Decrypt the config for send-time use. Null when unconfigured, disabled, or no key. */
|
||||
async resolve(scopeId: string, providerType: string): Promise<ProviderConfig | null> {
|
||||
const row = await this.prisma.iiosProviderCredential.findUnique({
|
||||
where: { scopeId_providerType: { scopeId, providerType } },
|
||||
});
|
||||
if (!row || !row.enabled) return null;
|
||||
const key = credKeyFromEnv(this.env);
|
||||
if (!key) return null;
|
||||
const sealed: SealedSecret = { cipherText: row.cipherText, iv: row.iv, authTag: row.authTag, keyVersion: row.keyVersion };
|
||||
return JSON.parse(openSecret(sealed, key)) as ProviderConfig;
|
||||
}
|
||||
|
||||
/** Masked view for the admin UI — configured + hints, never the secret. */
|
||||
async status(scopeId: string, providerType: string): Promise<CredentialStatus> {
|
||||
const row = await this.prisma.iiosProviderCredential.findUnique({
|
||||
where: { scopeId_providerType: { scopeId, providerType } },
|
||||
});
|
||||
if (!row) return { configured: false };
|
||||
return { configured: true, enabled: row.enabled, hints: (row.displayHints ?? {}) as Record<string, unknown> };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { credKeyFromEnv, sealSecret, openSecret, SECRET_KEY_VERSION } from './secret-crypto';
|
||||
|
||||
const KEY = Buffer.alloc(32, 7); // deterministic 32-byte key for tests
|
||||
|
||||
describe('secret-crypto', () => {
|
||||
it('round-trips a secret through seal → open', () => {
|
||||
const sealed = sealSecret('sk_live_abc123', KEY);
|
||||
expect(sealed.keyVersion).toBe(SECRET_KEY_VERSION);
|
||||
expect(sealed.cipherText).not.toContain('sk_live_abc123');
|
||||
expect(openSecret(sealed, KEY)).toBe('sk_live_abc123');
|
||||
});
|
||||
|
||||
it('produces a distinct ciphertext each time (random IV)', () => {
|
||||
const a = sealSecret('same', KEY);
|
||||
const b = sealSecret('same', KEY);
|
||||
expect(a.cipherText).not.toBe(b.cipherText);
|
||||
expect(a.iv).not.toBe(b.iv);
|
||||
});
|
||||
|
||||
it('fails to open with the wrong key', () => {
|
||||
const sealed = sealSecret('top-secret', KEY);
|
||||
expect(() => openSecret(sealed, Buffer.alloc(32, 9))).toThrow();
|
||||
});
|
||||
|
||||
it('fails to open if the ciphertext is tampered (GCM auth)', () => {
|
||||
const sealed = sealSecret('top-secret', KEY);
|
||||
const bad = { ...sealed, cipherText: Buffer.from('deadbeef', 'hex').toString('base64') };
|
||||
expect(() => openSecret(bad, KEY)).toThrow();
|
||||
});
|
||||
|
||||
it('credKeyFromEnv returns null when unset and rejects a wrong-length key', () => {
|
||||
expect(credKeyFromEnv({})).toBeNull();
|
||||
expect(() => credKeyFromEnv({ IIOS_CRED_KEY: Buffer.alloc(16).toString('base64') })).toThrow(/32 bytes/);
|
||||
const key = credKeyFromEnv({ IIOS_CRED_KEY: KEY.toString('base64') });
|
||||
expect(key?.length).toBe(32);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Envelope for a tenant secret sealed with AES-256-GCM. Stored as base64 columns on
|
||||
* IiosProviderCredential; the plaintext (a provider config JSON string) never touches disk.
|
||||
*/
|
||||
export interface SealedSecret {
|
||||
cipherText: string;
|
||||
iv: string;
|
||||
authTag: string;
|
||||
keyVersion: number;
|
||||
}
|
||||
|
||||
/** Bumped only when the platform master key rotates; lets old rows decrypt under an old key. */
|
||||
export const SECRET_KEY_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Resolve the 32-byte platform master key from `IIOS_CRED_KEY` (base64). Returns null when
|
||||
* unset (so egress providers that need it stay unregistered / fail closed) but THROWS on a
|
||||
* present-but-malformed key — a wrong-length key is an operator error, not a "disabled" state.
|
||||
*/
|
||||
export function credKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Buffer | null {
|
||||
const raw = env.IIOS_CRED_KEY;
|
||||
if (!raw) return null;
|
||||
const key = Buffer.from(raw, 'base64');
|
||||
if (key.length !== 32) throw new Error('IIOS_CRED_KEY must decode to 32 bytes (base64-encoded 256-bit key)');
|
||||
return key;
|
||||
}
|
||||
|
||||
export function sealSecret(plaintext: string, key: Buffer): SealedSecret {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
return {
|
||||
cipherText: enc.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
authTag: cipher.getAuthTag().toString('base64'),
|
||||
keyVersion: SECRET_KEY_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export function openSecret(sealed: SealedSecret, key: Buffer): string {
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(sealed.cipherText, 'base64')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
@@ -133,3 +133,42 @@ describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
|
||||
expect(await r('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BYO SMTP (scope-aware)', () => {
|
||||
const TENANT: SmtpIdentity = { host: 'smtp.acme.com', port: 465, secure: true, user: 'apikey', pass: 't', from: 'Acme <no-reply@acme.com>' };
|
||||
const scoped = (payload: Record<string, unknown>, scopeId?: string): CapabilityRequest => ({
|
||||
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1', ...(scopeId ? { scopeId } : {}),
|
||||
});
|
||||
|
||||
it('sends from the tenant identity when a scope has its own SMTP', async () => {
|
||||
const s = stub();
|
||||
const p = new SmtpProvider(ID, FB, s.make, undefined, async (sid) => (sid === 'scope_1' ? TENANT : null));
|
||||
const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_1'));
|
||||
expect(res.outcome).toBe('SENT');
|
||||
expect(s.calls[0]!.id).toEqual(TENANT);
|
||||
expect(s.calls[0]!.mail.from).toBe('Acme <no-reply@acme.com>');
|
||||
});
|
||||
|
||||
it('falls back to the platform identity when the scope has no SMTP', async () => {
|
||||
const s = stub();
|
||||
const p = new SmtpProvider(ID, FB, s.make, undefined, async () => null);
|
||||
await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_2'));
|
||||
expect(s.calls[0]!.id).toEqual(ID);
|
||||
});
|
||||
|
||||
it('fails closed (NOT_CONFIGURED) when there is neither a tenant nor a platform identity', async () => {
|
||||
const s = stub();
|
||||
const p = new SmtpProvider(undefined, undefined, s.make, undefined, async () => null);
|
||||
const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_3'));
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
||||
expect(s.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('smtpIdentityFromConfig maps a stored config (fromName + email → from)', async () => {
|
||||
const { smtpIdentityFromConfig } = await import('./smtp.provider');
|
||||
expect(smtpIdentityFromConfig({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', fromEmail: 'a@b.com', fromName: 'Acme' }))
|
||||
.toEqual({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', from: 'Acme <a@b.com>' });
|
||||
expect(smtpIdentityFromConfig({ host: 'h', user: 'u', pass: 'p' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,29 @@ function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | nu
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a tenant's stored SMTP config (BYO) to a sending identity, or null if incomplete. */
|
||||
export function smtpIdentityFromConfig(c: Record<string, unknown> | null | undefined): SmtpIdentity | null {
|
||||
if (!c) return null;
|
||||
const s = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
|
||||
const host = s(c.host);
|
||||
const user = s(c.user);
|
||||
const pass = s(c.pass);
|
||||
const fromEmail = s(c.fromEmail);
|
||||
if (!host || !user || !pass || !fromEmail) return null;
|
||||
const fromName = s(c.fromName);
|
||||
return {
|
||||
host,
|
||||
port: Number(c.port) || 587,
|
||||
secure: c.secure === true || c.secure === 'true',
|
||||
user,
|
||||
pass,
|
||||
from: fromName ? `${fromName} <${fromEmail}>` : fromEmail,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a scope's own SMTP identity (BYO), decrypting the stored credential. Null when unset. */
|
||||
export type SmtpCredResolver = (scopeId: string) => Promise<SmtpIdentity | null>;
|
||||
|
||||
interface EmailPayload {
|
||||
subject?: string;
|
||||
text?: string;
|
||||
@@ -107,10 +130,12 @@ export class SmtpProvider implements CapabilityProvider {
|
||||
private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
|
||||
|
||||
constructor(
|
||||
private readonly primary: SmtpIdentity,
|
||||
private readonly primary: SmtpIdentity | undefined,
|
||||
private readonly fallback?: SmtpIdentity,
|
||||
makeTransport?: (id: SmtpIdentity) => MailTransport,
|
||||
private readonly resolveAttachment?: AttachmentResolver,
|
||||
/** BYO: resolve the tenant's own SMTP identity per scope; used before the env identity. */
|
||||
private readonly credResolver?: SmtpCredResolver,
|
||||
) {
|
||||
this.makeTransport = makeTransport ?? defaultTransport;
|
||||
}
|
||||
@@ -119,6 +144,13 @@ export class SmtpProvider implements CapabilityProvider {
|
||||
const started = Date.now();
|
||||
const p = (req.payload ?? {}) as EmailPayload;
|
||||
|
||||
// BYO SMTP: a tenant's own identity wins over the platform env identity. The env fallback
|
||||
// (accounts@ → ceo@) applies ONLY to the platform identity, never to a tenant's own server.
|
||||
const tenant = req.scopeId && this.credResolver ? await this.credResolver(req.scopeId).catch(() => null) : null;
|
||||
const identity = tenant ?? this.primary;
|
||||
if (!identity) return this.failed('NOT_CONFIGURED', started);
|
||||
const fallback = tenant ? undefined : this.fallback;
|
||||
|
||||
// Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing
|
||||
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
|
||||
let attachments: MailAttachment[] | undefined;
|
||||
@@ -145,13 +177,13 @@ export class SmtpProvider implements CapabilityProvider {
|
||||
});
|
||||
|
||||
try {
|
||||
const info = await attempt(this.primary);
|
||||
const info = await attempt(identity);
|
||||
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
|
||||
} catch (err) {
|
||||
// Retry via the fallback identity ONLY if the primary never got the message accepted.
|
||||
if (this.fallback && isPreAcceptanceFailure(err)) {
|
||||
if (fallback && isPreAcceptanceFailure(err)) {
|
||||
try {
|
||||
const info = await attempt(this.fallback);
|
||||
const info = await attempt(fallback);
|
||||
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
|
||||
} catch (err2) {
|
||||
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started };
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { CapabilityRequest } from '@insignia/iios-contracts';
|
||||
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
|
||||
|
||||
const CREDS: TwilioCreds = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
|
||||
|
||||
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
|
||||
capability: 'channel.send',
|
||||
channelType: 'SMS',
|
||||
scopeId: 'scope_1',
|
||||
target: '+15557654321',
|
||||
payload: { text: 'hello world' },
|
||||
idempotencyKey: 'idem-1',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('TwilioSmsProvider', () => {
|
||||
it('resolves the scope creds and POSTs to Twilio, returning SENT with the message SID', async () => {
|
||||
const http = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ sid: 'SM999' }), text: async () => '' });
|
||||
const resolve = vi.fn().mockResolvedValue(CREDS);
|
||||
const p = new TwilioSmsProvider(resolve, http);
|
||||
|
||||
const res = await p.send(req());
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith('scope_1');
|
||||
const [url, init] = http.mock.calls[0];
|
||||
expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.authorization).toBe(`Basic ${Buffer.from('AC123:tok_secret').toString('base64')}`);
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('To')).toBe('+15557654321');
|
||||
expect(body.get('From')).toBe('+15550001111');
|
||||
expect(body.get('Body')).toBe('hello world');
|
||||
expect(res).toMatchObject({ outcome: 'SENT', providerRef: 'SM999' });
|
||||
});
|
||||
|
||||
it('fails closed as NOT_CONFIGURED when the scope has no creds', async () => {
|
||||
const http = vi.fn();
|
||||
const p = new TwilioSmsProvider(async () => null, http);
|
||||
const res = await p.send(req());
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
||||
expect(http).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed as NOT_CONFIGURED when the request has no scope', async () => {
|
||||
const p = new TwilioSmsProvider(async () => CREDS, vi.fn());
|
||||
const res = await p.send(req({ scopeId: undefined }));
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
||||
});
|
||||
|
||||
it('surfaces a Twilio API error as FAILED (never throws)', async () => {
|
||||
const http = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ code: 20003, message: 'Authenticate' }), text: async () => 'Authenticate' });
|
||||
const p = new TwilioSmsProvider(async () => CREDS, http);
|
||||
const res = await p.send(req());
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('TWILIO_20003');
|
||||
});
|
||||
|
||||
it('surfaces a transport throw as FAILED', async () => {
|
||||
const http = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
const p = new TwilioSmsProvider(async () => CREDS, http);
|
||||
const res = await p.send(req());
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
|
||||
|
||||
/** The decrypted Twilio config a tenant configures (BYO credentials). */
|
||||
export interface TwilioCreds {
|
||||
accountSid: string;
|
||||
authToken: string;
|
||||
fromNumber: string;
|
||||
}
|
||||
|
||||
/** Resolves a scope's Twilio creds (decrypting on demand); null when unconfigured/disabled. */
|
||||
export type TwilioCredResolver = (scopeId: string) => Promise<TwilioCreds | null>;
|
||||
|
||||
/** Injectable HTTP sender so tests don't hit the network; prod uses fetch. */
|
||||
export type HttpSender = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
text: () => Promise<string>;
|
||||
}>;
|
||||
|
||||
const defaultSender: HttpSender = (url, init) => fetch(url, init) as unknown as ReturnType<HttpSender>;
|
||||
|
||||
/**
|
||||
* SMS egress via Twilio, using the CALLER'S OWN credentials (resolved per scope at send time).
|
||||
* Fail-closed: no scope or no configured creds ⇒ FAILED/NOT_CONFIGURED, nothing sent. A Twilio
|
||||
* API error is surfaced as FAILED (never thrown), per the provider doctrine.
|
||||
*/
|
||||
export class TwilioSmsProvider implements CapabilityProvider {
|
||||
readonly name = 'twilio-sms';
|
||||
readonly channelTypes = ['SMS'];
|
||||
readonly capabilities = { canSend: true, maxPayloadBytes: 1600 };
|
||||
|
||||
constructor(
|
||||
private readonly resolve: TwilioCredResolver,
|
||||
private readonly http: HttpSender = defaultSender,
|
||||
) {}
|
||||
|
||||
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
||||
const started = Date.now();
|
||||
if (!req.scopeId) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
|
||||
|
||||
const creds = await this.resolve(req.scopeId).catch(() => null);
|
||||
if (!creds) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
|
||||
|
||||
const body = new URLSearchParams({
|
||||
To: req.target,
|
||||
From: creds.fromNumber,
|
||||
Body: String(req.payload.text ?? req.payload.body ?? ''),
|
||||
}).toString();
|
||||
|
||||
try {
|
||||
const res = await this.http(`https://api.twilio.com/2010-04-01/Accounts/${creds.accountSid}/Messages.json`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Basic ${Buffer.from(`${creds.accountSid}:${creds.authToken}`).toString('base64')}`,
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
});
|
||||
const latencyMs = Date.now() - started;
|
||||
const payload = (await res.json().catch(() => ({}))) as { sid?: string; code?: number; message?: string };
|
||||
if (!res.ok) {
|
||||
return { providerRef: `twilio-${res.status}`, outcome: 'FAILED', errorCode: payload.code ? `TWILIO_${payload.code}` : `HTTP_${res.status}`, latencyMs };
|
||||
}
|
||||
return { providerRef: payload.sid ?? 'twilio-sent', outcome: 'SENT', latencyMs };
|
||||
} catch (err) {
|
||||
return { providerRef: 'twilio-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,11 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const asService = prisma as unknown as PrismaService;
|
||||
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
|
||||
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService));
|
||||
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService), asService);
|
||||
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// point storage at an isolated temp dir for the test run
|
||||
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
|
||||
|
||||
@@ -70,3 +72,51 @@ describe('MediaService (presigned local storage)', () => {
|
||||
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaService orphan sweep', () => {
|
||||
async function upload(s: MediaService): Promise<string> {
|
||||
const bytes = Buffer.from('orphan-candidate');
|
||||
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
|
||||
await s.put(tokenFrom(uploadUrl), bytes);
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
it('put() records a tracking row for the stored object', async () => {
|
||||
const key = await upload(svc());
|
||||
const row = await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } });
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.mime).toBe('image/png');
|
||||
});
|
||||
|
||||
it('reaps an object that is past the grace window and unreferenced', async () => {
|
||||
const s = svc();
|
||||
const key = await upload(s);
|
||||
// Simulate the grace window having elapsed by sweeping "in the future".
|
||||
const deleted = await s.sweepOrphans(Date.now() + 2 * DAY_MS);
|
||||
expect(deleted).toBe(1);
|
||||
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an object still within the grace window', async () => {
|
||||
const s = svc();
|
||||
const key = await upload(s);
|
||||
expect(await s.sweepOrphans(Date.now())).toBe(0);
|
||||
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an object a message part references, even past grace', async () => {
|
||||
const s = svc();
|
||||
const key = await upload(s);
|
||||
const scope = await new ActorResolver(asService).resolveScope(alice);
|
||||
await prisma.iiosInteraction.create({
|
||||
data: {
|
||||
scopeId: scope.id,
|
||||
kind: 'MESSAGE',
|
||||
idempotencyKey: 'ref-1',
|
||||
parts: { create: [{ partIndex: 0, kind: 'MEDIA_REF', contentRef: key }] },
|
||||
},
|
||||
});
|
||||
expect(await s.sweepOrphans(Date.now() + 2 * DAY_MS)).toBe(0);
|
||||
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit, PayloadTooLargeException } from '@nestjs/common';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { decideOrThrow } from '../platform/fail-closed';
|
||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||
import { STORAGE_PORT, type StoragePort } from './storage.port';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
|
||||
interface DownloadToken { op: 'get'; objectKey: string; mime: string }
|
||||
|
||||
@@ -17,16 +20,35 @@ interface DownloadToken { op: 'get'; objectKey: string; mime: string }
|
||||
* kernel only ever stores the object key (contentRef) on a MessagePart.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
export class MediaService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
|
||||
private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, '');
|
||||
/** Objects unreferenced by any message part AND older than this are reaped by the sweep. The grace
|
||||
* window must exceed the longest realistic compose time so an attached-but-unsent file survives. */
|
||||
private readonly orphanGraceMs = Number(process.env.IIOS_MEDIA_ORPHAN_GRACE_MS ?? DAY_MS);
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
private gcTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||
private readonly actors: ActorResolver,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const ms = Number(process.env.IIOS_MEDIA_GC_INTERVAL_MS ?? 0);
|
||||
if (ms > 0) {
|
||||
this.gcTimer = setInterval(() => {
|
||||
void this.sweepOrphans().catch((err) => this.logger.warn(`media orphan sweep failed: ${(err as Error).message}`));
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.gcTimer) clearInterval(this.gcTimer);
|
||||
}
|
||||
|
||||
/** Authorize an upload and return a short-lived signed PUT url + the object key. */
|
||||
async presignUpload(
|
||||
principal: MessagePrincipal,
|
||||
@@ -44,9 +66,58 @@ export class MediaService {
|
||||
const t = this.verify<UploadToken>(token, 'put');
|
||||
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
|
||||
const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime);
|
||||
// Track the object so the orphan sweep can reap it if it's never referenced by a message part.
|
||||
// Best-effort: tracking must never fail an otherwise-successful upload. scopeId is the key prefix.
|
||||
const scopeId = t.objectKey.split('/')[0] ?? 'unknown';
|
||||
await this.prisma.iiosMediaObject
|
||||
.upsert({
|
||||
where: { objectKey: t.objectKey },
|
||||
create: { scopeId, objectKey: t.objectKey, mime: t.mime, sizeBytes: BigInt(sizeBytes) },
|
||||
update: { sizeBytes: BigInt(sizeBytes) },
|
||||
})
|
||||
.catch((err) => this.logger.warn(`media tracking upsert failed for ${t.objectKey}: ${(err as Error).message}`));
|
||||
return { objectKey: t.objectKey, sizeBytes, checksumSha256 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap orphaned media: objects older than the grace window that NO message part references. A
|
||||
* file attached in a composer uploads immediately (direct-to-storage), so an abandoned attach
|
||||
* (removed, cancelled, tab closed) would otherwise linger forever — there is no draft/commit step.
|
||||
* "Referenced" is derived live from IiosMessagePart.contentRef, so nothing can drift. Returns the
|
||||
* number of objects deleted. Safe to call repeatedly (idempotent); storage delete is best-effort.
|
||||
*/
|
||||
async sweepOrphans(now: number = Date.now(), batch = 1000): Promise<number> {
|
||||
const cutoff = new Date(now - this.orphanGraceMs);
|
||||
const candidates = await this.prisma.iiosMediaObject.findMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
select: { id: true, objectKey: true },
|
||||
take: batch,
|
||||
});
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
const keys = candidates.map((c) => c.objectKey);
|
||||
const referenced = new Set(
|
||||
(
|
||||
await this.prisma.iiosMessagePart.findMany({
|
||||
where: { contentRef: { in: keys } },
|
||||
select: { contentRef: true },
|
||||
})
|
||||
)
|
||||
.map((p) => p.contentRef)
|
||||
.filter((ref): ref is string => ref !== null),
|
||||
);
|
||||
|
||||
const orphans = candidates.filter((c) => !referenced.has(c.objectKey));
|
||||
let deleted = 0;
|
||||
for (const o of orphans) {
|
||||
await this.storage.remove(o.objectKey).catch((err) => this.logger.warn(`storage remove failed for ${o.objectKey}: ${(err as Error).message}`));
|
||||
await this.prisma.iiosMediaObject.delete({ where: { id: o.id } }).catch(() => undefined);
|
||||
deleted += 1;
|
||||
}
|
||||
if (deleted > 0) this.logger.log(`media orphan sweep reaped ${deleted} object(s)`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Authorize a download and return a short-lived signed GET url (tenant-fenced). */
|
||||
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
|
||||
const scope = await this.actors.resolveScope(principal);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MessageGateway } from './message.gateway';
|
||||
import type { MessageService, MessagePrincipal } from './message.service';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
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 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,
|
||||
session,
|
||||
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 { Logger } from '@nestjs/common';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
@@ -16,7 +16,7 @@ import { logJson } from '../observability/logger';
|
||||
import { MessageService, type MessagePrincipal } from './message.service';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
import { OutboxBus } from '../outbox/outbox.bus';
|
||||
import { PresenceService } from '../notifications/presence.service';
|
||||
import { PRESENCE_PORT, type PresencePort } from '../notifications/presence.port';
|
||||
|
||||
interface SocketState {
|
||||
principal: MessagePrincipal;
|
||||
@@ -39,7 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
||||
private readonly messages: MessageService,
|
||||
private readonly session: SessionVerifier,
|
||||
private readonly bus: OutboxBus,
|
||||
private readonly presence: PresenceService,
|
||||
@Inject(PRESENCE_PORT) private readonly presence: PresencePort,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -77,7 +77,8 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -85,7 +86,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
||||
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
|
||||
const state = client.data as SocketState | undefined;
|
||||
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')
|
||||
@@ -156,7 +159,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
||||
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
||||
const { principal } = client.data as SocketState;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -164,7 +169,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
||||
async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
||||
const { principal } = client.data as SocketState;
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,21 @@ import { MessageService } from './message.service';
|
||||
import { MessageGateway } from './message.gateway';
|
||||
import { OutboxModule } from '../outbox/outbox.module';
|
||||
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({
|
||||
imports: [OutboxModule],
|
||||
providers: [MessageService, MessageGateway, PresenceService],
|
||||
exports: [MessageService, PresenceService],
|
||||
providers: [MessageService, MessageGateway, presenceProvider],
|
||||
exports: [MessageService, PRESENCE_PORT],
|
||||
})
|
||||
export class MessageModule {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -74,6 +74,10 @@ export interface OpenThreadResult {
|
||||
*/
|
||||
@Injectable()
|
||||
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(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||
@@ -167,6 +171,76 @@ export class MessageService {
|
||||
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
|
||||
* 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 }>> {
|
||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||
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({
|
||||
where: { threadId },
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const s = gov();
|
||||
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' } });
|
||||
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') {
|
||||
const deliver = vi.fn().mockResolvedValue(deliverResult);
|
||||
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 m.send(threadId, alice, { content: 'hi bob' }, 'k1');
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(deliver).toHaveBeenCalledOnce();
|
||||
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||
});
|
||||
@@ -63,7 +75,7 @@ describe('NotificationProjector', () => {
|
||||
await seedSub('bob');
|
||||
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -74,7 +86,7 @@ describe('NotificationProjector', () => {
|
||||
await seedSub('bob');
|
||||
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
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)
|
||||
const { proj, deliver } = makeProjector();
|
||||
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.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||
});
|
||||
@@ -102,7 +114,7 @@ describe('NotificationProjector', () => {
|
||||
const presence = new PresenceService();
|
||||
presence.setFocus('sockB', 'bob', threadId);
|
||||
const { proj, deliver } = makeProjector(presence);
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -114,7 +126,31 @@ describe('NotificationProjector', () => {
|
||||
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
|
||||
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -125,7 +161,7 @@ describe('NotificationProjector', () => {
|
||||
await seedSub('bob');
|
||||
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OutboxBus } from '../outbox/outbox.bus';
|
||||
import { DlqService } from '../outbox/dlq.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';
|
||||
|
||||
interface MsgData {
|
||||
@@ -14,14 +14,22 @@ interface MsgData {
|
||||
mentions?: string[];
|
||||
}
|
||||
|
||||
interface NormalizedData {
|
||||
interactionId: string;
|
||||
threadId: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns `message.sent` into push notifications for absent recipients. Three gates:
|
||||
* 1. policy — DM always; group only if @mentioned or a reply to that recipient
|
||||
* 2. presence — skip if the recipient is currently focused on that thread
|
||||
* 3. mute — skip if the recipient muted the thread
|
||||
* Turns messenger sends AND inbound mail into push notifications for absent recipients:
|
||||
* - `message.sent` → messenger policy (DM always; group only if @mentioned or reply-to-you)
|
||||
* - `interaction.normalized` (kind EMAIL) → mail always notifies the recipient (a directed 1:1)
|
||||
* 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.
|
||||
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group is read
|
||||
* from the opaque `membership` thread attribute here in the notification *policy*, not the kernel.
|
||||
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group / mail is read
|
||||
* from opaque thread attributes here in the notification *policy*, not the kernel.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationProjector implements OnModuleInit {
|
||||
@@ -32,36 +40,63 @@ export class NotificationProjector implements OnModuleInit {
|
||||
private readonly bus: OutboxBus,
|
||||
private readonly dlq: DlqService,
|
||||
private readonly cursor: ProjectionCursorService,
|
||||
private readonly presence: PresenceService,
|
||||
@Inject(PRESENCE_PORT) private readonly presence: PresencePort,
|
||||
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
|
||||
) {}
|
||||
|
||||
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) =>
|
||||
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
|
||||
await this.apply(event);
|
||||
await this.cursor.advance(this.consumer, event);
|
||||
}
|
||||
|
||||
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 thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
|
||||
if (!thread) return;
|
||||
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({
|
||||
where: { id: data.interactionId },
|
||||
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
||||
});
|
||||
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
|
||||
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
|
||||
const mentions = data.mentions ?? [];
|
||||
|
||||
// Parent author (for reply-to-you) — one lookup.
|
||||
let parentAuthorActorId: string | null = null;
|
||||
@@ -85,10 +120,10 @@ export class NotificationProjector implements OnModuleInit {
|
||||
// gate 1 — policy
|
||||
const mentioned = userId != null && mentions.includes(userId);
|
||||
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
|
||||
if (!(membership === 'dm' || mentioned || repliedToMe)) continue;
|
||||
if (!(alwaysNotify || mentioned || repliedToMe)) continue;
|
||||
|
||||
// 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
|
||||
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';
|
||||
|
||||
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();
|
||||
expect(p.isViewing('alice', 'T1')).toBe(false);
|
||||
p.setFocus('sock1', 'alice', 'T1');
|
||||
expect(p.isViewing('alice', 'T1')).toBe(true);
|
||||
expect(p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
|
||||
p.setFocus('sock1', 'alice', 'T2'); // moved focus
|
||||
expect(p.isViewing('alice', 'T1')).toBe(false);
|
||||
expect(p.isViewing('alice', 'T2')).toBe(true);
|
||||
p.clearSocket('sock1');
|
||||
expect(p.isViewing('alice', 'T2')).toBe(false);
|
||||
expect(await p.isViewing('alice', 'T1')).toBe(false);
|
||||
await p.setFocus('sock1', 'alice', 'T1');
|
||||
expect(await p.isViewing('alice', 'T1')).toBe(true);
|
||||
expect(await p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
|
||||
await p.setFocus('sock1', 'alice', 'T2'); // moved focus
|
||||
expect(await p.isViewing('alice', 'T1')).toBe(false);
|
||||
expect(await p.isViewing('alice', 'T2')).toBe(true);
|
||||
await p.clearSocket('sock1');
|
||||
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();
|
||||
p.setFocus('sockA', 'bob', 'T9');
|
||||
p.setFocus('sockB', 'bob', null); // a second tab, no focus
|
||||
expect(p.isViewing('bob', 'T9')).toBe(true);
|
||||
await p.setFocus('sockA', 'bob', 'T9');
|
||||
await p.setFocus('sockB', 'bob', null); // a second tab, no focus
|
||||
expect(await p.isViewing('bob', 'T9')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { PresencePort } from './presence.port';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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()
|
||||
export class PresenceService {
|
||||
export class PresenceService implements PresencePort {
|
||||
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 });
|
||||
}
|
||||
|
||||
clearSocket(socketId: string): void {
|
||||
async clearSocket(socketId: string): Promise<void> {
|
||||
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;
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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']) {
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface OpaInput {
|
||||
membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel'
|
||||
visibility?: string; // 'public' | 'private' (channels)
|
||||
participantCount?: number;
|
||||
targetCount?: number; // how many participants a single add call is inviting (1 for a single add)
|
||||
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
||||
alreadyMember?: boolean;
|
||||
isMember?: boolean;
|
||||
@@ -38,11 +39,23 @@ export class DevOpaPort {
|
||||
switch (i.action) {
|
||||
case 'iios.thread.participant.add': {
|
||||
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.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||
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': {
|
||||
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
|
||||
// (no membership attr) threads allow removal by any member.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { MeiliSearch } from 'meilisearch';
|
||||
import type { MessageSearchPort, SearchDoc, SearchHit } from './message-search.port';
|
||||
|
||||
const INDEX = 'iios_messages';
|
||||
|
||||
/** A no-op search port — bound when Meilisearch isn't configured, so search degrades to empty. */
|
||||
export const noopMessageSearch: MessageSearchPort = {
|
||||
ready: () => false,
|
||||
index: async () => undefined,
|
||||
remove: async () => undefined,
|
||||
search: async () => [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Meilisearch-backed message search. Configured via MEILI_URL (+ optional MEILI_KEY). The index is
|
||||
* created + configured on first use (searchable text/subject; filterable scopeId/threadId/source;
|
||||
* sortable at). Filtering by scopeId AND threadId is the permission fence — the caller only ever
|
||||
* passes thread ids they belong to.
|
||||
*/
|
||||
export class MeiliMessageSearch implements MessageSearchPort {
|
||||
private readonly client: MeiliSearch;
|
||||
private readonly logger = new Logger(MeiliMessageSearch.name);
|
||||
private settingsReady?: Promise<void>;
|
||||
|
||||
constructor(host: string, apiKey?: string) {
|
||||
this.client = new MeiliSearch({ host, ...(apiKey ? { apiKey } : {}) });
|
||||
}
|
||||
|
||||
ready(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Idempotently ensure the index + its attribute settings exist (runs once, memoised). */
|
||||
private ensure(): Promise<void> {
|
||||
if (!this.settingsReady) {
|
||||
this.settingsReady = (async () => {
|
||||
await this.client.createIndex(INDEX, { primaryKey: 'id' }).catch(() => undefined);
|
||||
await this.client.index(INDEX).updateSettings({
|
||||
searchableAttributes: ['text', 'subject'],
|
||||
filterableAttributes: ['scopeId', 'threadId', 'source'],
|
||||
sortableAttributes: ['at'],
|
||||
});
|
||||
})().catch((err) => {
|
||||
this.settingsReady = undefined; // let a later call retry
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return this.settingsReady;
|
||||
}
|
||||
|
||||
async index(docs: SearchDoc[]): Promise<void> {
|
||||
if (docs.length === 0) return;
|
||||
await this.ensure();
|
||||
await this.client.index(INDEX).addDocuments(docs, { primaryKey: 'id' });
|
||||
}
|
||||
|
||||
async remove(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
await this.client.index(INDEX).deleteDocuments(ids);
|
||||
}
|
||||
|
||||
async search(scopeId: string, threadIds: string[], query: string, limit: number): Promise<SearchHit[]> {
|
||||
if (threadIds.length === 0 || !query.trim()) return [];
|
||||
await this.ensure();
|
||||
const quoted = threadIds.map((t) => JSON.stringify(t)).join(', ');
|
||||
const res = await this.client.index(INDEX).search(query, {
|
||||
filter: `scopeId = ${JSON.stringify(scopeId)} AND threadId IN [${quoted}]`,
|
||||
limit,
|
||||
sort: ['at:desc'],
|
||||
attributesToHighlight: ['text'],
|
||||
highlightPreTag: '<em>',
|
||||
highlightPostTag: '</em>',
|
||||
attributesToCrop: ['text'],
|
||||
cropLength: 30,
|
||||
});
|
||||
return res.hits.map((h) => {
|
||||
const doc = h as unknown as SearchDoc & { _formatted?: { text?: string } };
|
||||
return {
|
||||
id: doc.id,
|
||||
threadId: doc.threadId,
|
||||
text: doc.text,
|
||||
subject: doc.subject ?? null,
|
||||
source: doc.source ?? null,
|
||||
at: doc.at,
|
||||
snippet: doc._formatted?.text ?? doc.text,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the search port from env: MEILI_URL set → Meilisearch; else the no-op. */
|
||||
export function messageSearchFromEnv(env: NodeJS.ProcessEnv = process.env): MessageSearchPort {
|
||||
const host = env.MEILI_URL?.trim();
|
||||
if (!host) {
|
||||
new Logger('MessageSearch').log('MEILI_URL unset — message search disabled (no-op)');
|
||||
return noopMessageSearch;
|
||||
}
|
||||
new Logger('MessageSearch').log(`using Meilisearch @ ${host}`);
|
||||
return new MeiliMessageSearch(host, env.MEILI_KEY?.trim() || undefined);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** One indexed message document. `at` is epoch-ms so Meilisearch can sort recency-first. */
|
||||
export interface SearchDoc {
|
||||
id: string; // interactionId
|
||||
scopeId: string;
|
||||
threadId: string;
|
||||
text: string;
|
||||
subject: string | null;
|
||||
source: string | null; // opaque app source tag (e.g. crm-messenger / crm-mail) — drives the surface
|
||||
actorId: string | null;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** A search hit with a highlighted snippet. */
|
||||
export interface SearchHit {
|
||||
id: string;
|
||||
threadId: string;
|
||||
text: string;
|
||||
subject: string | null;
|
||||
source: string | null;
|
||||
at: number;
|
||||
/** The match with `<em>…</em>` around the query terms (from the engine's highlighter). */
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message-search seam. IIOS owns egress-style search: a message index + a permission-scoped
|
||||
* query. The concrete engine (Meilisearch) sits behind this; an unconfigured deployment binds a
|
||||
* no-op so search degrades to empty rather than erroring.
|
||||
*/
|
||||
export interface MessageSearchPort {
|
||||
/** True only when a real engine is configured (else index/search are inert). */
|
||||
ready(): boolean;
|
||||
index(docs: SearchDoc[]): Promise<void>;
|
||||
remove(ids: string[]): Promise<void>;
|
||||
/** Search WITHIN the given scope and thread set only (the permission fence — enforced here). */
|
||||
search(scopeId: string, threadIds: string[], query: string, limit: number): Promise<SearchHit[]>;
|
||||
}
|
||||
|
||||
export const MESSAGE_SEARCH_PORT = Symbol('MESSAGE_SEARCH_PORT');
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
interface SearchDto { query?: string; limit?: number }
|
||||
|
||||
/** Message search (session-auth). Results are permission-scoped inside the service to the caller's
|
||||
* own scope + threads. Reindex backfills the caller's scope (idempotent). */
|
||||
@Controller('v1/search')
|
||||
export class SearchController {
|
||||
constructor(
|
||||
private readonly search: SearchService,
|
||||
private readonly session: SessionVerifier,
|
||||
private readonly actors: ActorResolver,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async run(@Body() body: SearchDto, @Headers('authorization') authorization?: string) {
|
||||
const principal = this.principal(authorization);
|
||||
return this.search.search(principal, { query: body.query ?? '', ...(body.limit != null ? { limit: body.limit } : {}) });
|
||||
}
|
||||
|
||||
@Post('reindex')
|
||||
async reindex(@Headers('authorization') authorization?: string) {
|
||||
const principal = this.principal(authorization);
|
||||
const scope = await this.actors.findScope(principal);
|
||||
return { indexed: await this.search.reindexAll(scope?.id) };
|
||||
}
|
||||
|
||||
private principal(authorization?: string): MessagePrincipal {
|
||||
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
||||
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||
return this.session.verify(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OutboxModule } from '../outbox/outbox.module';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchProjector } from './search.projector';
|
||||
import { SearchController } from './search.controller';
|
||||
import { MESSAGE_SEARCH_PORT } from './message-search.port';
|
||||
import { messageSearchFromEnv } from './meili.adapter';
|
||||
|
||||
/**
|
||||
* Message search (Meilisearch). The engine binds from MEILI_URL; unset → a no-op port so search
|
||||
* returns empty rather than erroring. The projector indexes on `message.sent`; the service enforces
|
||||
* the permission fence (scope + caller's threads). SessionVerifier + ActorResolver are global.
|
||||
*/
|
||||
@Module({
|
||||
imports: [OutboxModule],
|
||||
controllers: [SearchController],
|
||||
providers: [
|
||||
SearchService,
|
||||
SearchProjector,
|
||||
{ provide: MESSAGE_SEARCH_PORT, useFactory: () => messageSearchFromEnv() },
|
||||
],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
|
||||
import { OutboxBus } from '../outbox/outbox.bus';
|
||||
import { DlqService } from '../outbox/dlq.service';
|
||||
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
/**
|
||||
* Indexes messages into the search engine as they are sent. Reacts to the `message.sent` event on
|
||||
* the outbox bus (same pattern as the inbox projector). Indexing is idempotent (upsert by id), so
|
||||
* no per-event claim is needed — a redelivered event just re-indexes the same doc.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchProjector implements OnModuleInit {
|
||||
private readonly consumer = 'search-projector';
|
||||
private readonly logger = new Logger(SearchProjector.name);
|
||||
|
||||
constructor(
|
||||
private readonly search: SearchService,
|
||||
private readonly bus: OutboxBus,
|
||||
private readonly dlq: DlqService,
|
||||
private readonly cursor: ProjectionCursorService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.dlq.registerHandler(this.consumer, (e) => this.onMessageSent(e));
|
||||
this.bus.on(IIOS_EVENTS.messageSent, (p) => void this.onMessageSent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)));
|
||||
}
|
||||
|
||||
async onMessageSent(event: CloudEvent): Promise<void> {
|
||||
const data = event.data as { interactionId?: string };
|
||||
if (data.interactionId) await this.search.indexInteraction(data.interactionId);
|
||||
await this.cursor.advance(this.consumer, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { SearchService } from './search.service';
|
||||
import type { MessageSearchPort, SearchHit } from './message-search.port';
|
||||
import type { ActorResolver, MessagePrincipal } from '../identity/actor.resolver';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const principal: MessagePrincipal = { userId: 'u1', orgId: 'org', appId: 'app' };
|
||||
|
||||
function make(opts: { ready?: boolean; threadIds?: string[]; hits?: SearchHit[]; scope?: { id: string } | null } = {}) {
|
||||
const engine: MessageSearchPort = {
|
||||
ready: () => opts.ready ?? true,
|
||||
index: vi.fn(async () => undefined),
|
||||
remove: vi.fn(async () => undefined),
|
||||
search: vi.fn(async () => opts.hits ?? []),
|
||||
};
|
||||
const actors = {
|
||||
findScope: vi.fn(async () => (opts.scope === undefined ? { id: 'scope_1' } : opts.scope)),
|
||||
resolveActor: vi.fn(async () => ({ id: 'actor_1' })),
|
||||
} as unknown as ActorResolver;
|
||||
const prisma = {
|
||||
iiosThreadParticipant: { findMany: vi.fn(async () => (opts.threadIds ?? ['t1', 't2']).map((threadId) => ({ threadId }))) },
|
||||
} as unknown as PrismaService;
|
||||
return { svc: new SearchService(engine, prisma, actors), engine };
|
||||
}
|
||||
|
||||
describe('SearchService (permission fence)', () => {
|
||||
it('searches only within the caller scope + their thread ids', async () => {
|
||||
const { svc, engine } = make({ threadIds: ['t1', 't2', 't3'], hits: [{ id: 'i1', threadId: 't2', text: 'hello world', subject: 'General', source: 'crm-messenger', at: 1, snippet: '<em>hello</em>' }] });
|
||||
const res = await svc.search(principal, { query: 'hello' });
|
||||
expect(res[0]!.id).toBe('i1');
|
||||
expect(engine.search).toHaveBeenCalledWith('scope_1', ['t1', 't2', 't3'], 'hello', 20);
|
||||
});
|
||||
|
||||
it('returns empty (no engine hit) for a blank query', async () => {
|
||||
const { svc, engine } = make();
|
||||
expect(await svc.search(principal, { query: ' ' })).toEqual([]);
|
||||
expect(engine.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty when the caller belongs to no threads', async () => {
|
||||
const { svc, engine } = make({ threadIds: [] });
|
||||
expect(await svc.search(principal, { query: 'hello' })).toEqual([]);
|
||||
expect(engine.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty when search is not configured (no-op engine)', async () => {
|
||||
const { svc, engine } = make({ ready: false });
|
||||
expect(await svc.search(principal, { query: 'hello' })).toEqual([]);
|
||||
expect(engine.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caps the limit at 50', async () => {
|
||||
const { svc, engine } = make();
|
||||
await svc.search(principal, { query: 'x', limit: 999 });
|
||||
expect((engine.search as ReturnType<typeof vi.fn>).mock.calls[0]![3]).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||
import { MESSAGE_SEARCH_PORT, type MessageSearchPort, type SearchDoc, type SearchHit } from './message-search.port';
|
||||
|
||||
/**
|
||||
* Message search. The permission fence lives HERE: a query only ever runs against the caller's own
|
||||
* scope AND the set of threads the caller currently belongs to (resolved live from participant
|
||||
* rows, so membership changes are reflected immediately). The engine never sees a thread the caller
|
||||
* isn't in. Text is drawn from the message's TEXT part; every conversation kind (chat, mail, sms…)
|
||||
* is a message with parts, so all are searchable through one index.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
@Inject(MESSAGE_SEARCH_PORT) private readonly engine: MessageSearchPort,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly actors: ActorResolver,
|
||||
) {}
|
||||
|
||||
async search(principal: MessagePrincipal, opts: { query: string; limit?: number }): Promise<SearchHit[]> {
|
||||
const q = (opts.query ?? '').trim();
|
||||
if (!q || !this.engine.ready()) return [];
|
||||
const scope = await this.actors.findScope(principal);
|
||||
if (!scope) return [];
|
||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||
const memberships = await this.prisma.iiosThreadParticipant.findMany({ where: { actorId: actor.id }, select: { threadId: true } });
|
||||
const threadIds = memberships.map((m) => m.threadId);
|
||||
if (threadIds.length === 0) return [];
|
||||
return this.engine.search(scope.id, threadIds, q, Math.min(opts.limit ?? 20, 50));
|
||||
}
|
||||
|
||||
/** Build + index the search doc for one interaction (called by the projector on message.sent). */
|
||||
async indexInteraction(interactionId: string): Promise<void> {
|
||||
if (!this.engine.ready()) return;
|
||||
const doc = await this.buildDoc(interactionId);
|
||||
if (doc) await this.engine.index([doc]);
|
||||
}
|
||||
|
||||
/** Remove an interaction from the index (retention / redaction hook). */
|
||||
async removeInteraction(interactionId: string): Promise<void> {
|
||||
if (this.engine.ready()) await this.engine.remove([interactionId]);
|
||||
}
|
||||
|
||||
/** Backfill: (re)index every text message, optionally for one scope. Returns the count indexed. */
|
||||
async reindexAll(scopeId?: string, batch = 500): Promise<number> {
|
||||
if (!this.engine.ready()) return 0;
|
||||
const interactions = await this.prisma.iiosInteraction.findMany({
|
||||
where: { ...(scopeId ? { scopeId } : {}), threadId: { not: null }, parts: { some: { kind: 'TEXT' } } },
|
||||
select: { id: true },
|
||||
});
|
||||
let indexed = 0;
|
||||
for (let i = 0; i < interactions.length; i += batch) {
|
||||
const docs = (await Promise.all(interactions.slice(i, i + batch).map((x) => this.buildDoc(x.id)))).filter((d): d is SearchDoc => d !== null);
|
||||
if (docs.length > 0) {
|
||||
await this.engine.index(docs);
|
||||
indexed += docs.length;
|
||||
}
|
||||
}
|
||||
return indexed;
|
||||
}
|
||||
|
||||
private async buildDoc(interactionId: string): Promise<SearchDoc | null> {
|
||||
const i = await this.prisma.iiosInteraction.findUnique({
|
||||
where: { id: interactionId },
|
||||
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, thread: true },
|
||||
});
|
||||
if (!i || !i.threadId || !i.thread) return null;
|
||||
const text = i.parts[0]?.bodyText?.trim();
|
||||
if (!text) return null; // nothing searchable
|
||||
const meta = (i.thread.metadata as { source?: string } | null) ?? {};
|
||||
return {
|
||||
id: i.id,
|
||||
scopeId: i.scopeId,
|
||||
threadId: i.threadId,
|
||||
text,
|
||||
subject: i.thread.subject ?? null,
|
||||
source: meta.source ?? null,
|
||||
actorId: i.actorId ?? null,
|
||||
at: i.occurredAt.getTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export async function resetDb(prisma: PrismaClient): Promise<void> {
|
||||
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
||||
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
||||
"IiosRouteDecision","IiosRouteBinding","IiosModerationFlag",
|
||||
"IiosInboundRawEvent","IiosDeliveryAttempt","IiosOutboundCommand","IiosRateLimitBucket",
|
||||
"IiosInboundRawEvent","IiosDeliveryAttempt","IiosOutboundCommand","IiosRateLimitBucket","IiosMediaObject",
|
||||
"IiosTicketStateHistory","IiosTicketThreadLink","IiosCallbackRequest","IiosTicket",
|
||||
"IiosSupportTeamMember","IiosSupportQueue",
|
||||
"IiosInboxItemStateHistory","IiosInboxItem","IiosUnreadCounter","IiosMessageReceipt",
|
||||
|
||||
@@ -70,6 +70,17 @@ export class ThreadsController {
|
||||
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. */
|
||||
@Get(':id/participants')
|
||||
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||
|
||||
Generated
+11
@@ -340,6 +340,9 @@ importers:
|
||||
'@types/react':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.17
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
jsdom:
|
||||
specifier: ^26.0.0
|
||||
version: 26.1.0
|
||||
@@ -412,6 +415,9 @@ importers:
|
||||
jwks-rsa:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
meilisearch:
|
||||
specifier: ^0.45.0
|
||||
version: 0.45.0
|
||||
nodemailer:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
@@ -2712,6 +2718,9 @@ packages:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
meilisearch@0.45.0:
|
||||
resolution: {integrity: sha512-+zCzEqE+CumY4icB0Vox180adZqaNtnr60hJWGiEdmol5eWmksfY8rYsTcz87styXC2ZOg+2yF56gdH6oyIBTA==}
|
||||
|
||||
memfs@3.5.3:
|
||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@@ -5899,6 +5908,8 @@ snapshots:
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
meilisearch@0.45.0: {}
|
||||
|
||||
memfs@3.5.3:
|
||||
dependencies:
|
||||
fs-monkey: 1.1.0
|
||||
|
||||
Reference in New Issue
Block a user