Merge pull request 'Feat/messaging ui foundation' (#11) from feat/messaging-ui-foundation into dev
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -21,3 +21,10 @@ IIOS_CRED_KEY=""
|
|||||||
# Orphaned-media garbage collection (uploaded-but-never-sent attachments).
|
# Orphaned-media garbage collection (uploaded-but-never-sent attachments).
|
||||||
# Interval 0 = off; set e.g. 3600000 (hourly) in prod. Grace defaults to 24h.
|
# Interval 0 = off; set e.g. 3600000 (hourly) in prod. Grace defaults to 24h.
|
||||||
IIOS_MEDIA_GC_INTERVAL_MS=0
|
IIOS_MEDIA_GC_INTERVAL_MS=0
|
||||||
|
|
||||||
|
# Web Push (VAPID) for offline notifications. Unset VAPID_PUBLIC_KEY → push is disabled
|
||||||
|
# (subscribe endpoint still stores subs, but WebPushDelivery returns 'failed' — no sends).
|
||||||
|
# Generate a keypair with: npx web-push generate-vapid-keys
|
||||||
|
VAPID_PUBLIC_KEY=""
|
||||||
|
VAPID_PRIVATE_KEY=""
|
||||||
|
VAPID_SUBJECT="mailto:dev@insignia"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-messaging-ui",
|
"name": "@insignia/iios-messaging-ui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export interface MessagingAdapter {
|
|||||||
|
|
||||||
markRead(threadId: string, messageId: string): Promise<void>;
|
markRead(threadId: string, messageId: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Report which thread is in the foreground (null when none/blurred) so the backend can suppress
|
||||||
|
* push notifications for a thread you're actively viewing. Absent => presence isn't tracked. */
|
||||||
|
setFocus?(threadId: string | null): void;
|
||||||
|
|
||||||
|
/** Subscribe to activity across ALL of the caller's threads (not just the open one) — fires for
|
||||||
|
* every incoming message. Drives live unread + in-app notifications. Absent => no cross-thread
|
||||||
|
* awareness. Returns an unsubscribe fn. */
|
||||||
|
subscribeActivity?(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current user's actor id, or null if not yet known.
|
* The current user's actor id, or null if not yet known.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -40,6 +40,29 @@ export function Messenger({ focusThreadId }: { focusThreadId?: string | null } =
|
|||||||
setSelected(focusThreadId);
|
setSelected(focusThreadId);
|
||||||
}, [focusThreadId]);
|
}, [focusThreadId]);
|
||||||
|
|
||||||
|
// Presence: tell the backend which thread is foregrounded so it suppresses push for it. Null while
|
||||||
|
// browsing/composing, when the window is blurred, and on unmount (leaving the messenger).
|
||||||
|
useEffect(() => {
|
||||||
|
const active = browsing || composing ? null : selected;
|
||||||
|
adapter.setFocus?.(active);
|
||||||
|
const onBlur = (): void => adapter.setFocus?.(null);
|
||||||
|
const onFocus = (): void => adapter.setFocus?.(active);
|
||||||
|
window.addEventListener('blur', onBlur);
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
return () => {
|
||||||
|
adapter.setFocus?.(null);
|
||||||
|
window.removeEventListener('blur', onBlur);
|
||||||
|
window.removeEventListener('focus', onFocus);
|
||||||
|
};
|
||||||
|
}, [adapter, selected, browsing, composing]);
|
||||||
|
|
||||||
|
// Live unread + ordering: refresh the conversation list when any of the caller's threads gets a
|
||||||
|
// message (not just the open one).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adapter.subscribeActivity) return;
|
||||||
|
return adapter.subscribeActivity(() => refetch());
|
||||||
|
}, [adapter, refetch]);
|
||||||
|
|
||||||
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
||||||
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
||||||
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
|
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MessageGateway } from './message.gateway';
|
|||||||
import type { MessageService, MessagePrincipal } from './message.service';
|
import type { MessageService, MessagePrincipal } from './message.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import type { OutboxBus } from '../outbox/outbox.bus';
|
import type { OutboxBus } from '../outbox/outbox.bus';
|
||||||
import type { PresenceService } from '../notifications/presence.service';
|
import type { PresencePort } from '../notifications/presence.port';
|
||||||
|
|
||||||
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
|
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
|
||||||
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
|
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
|
||||||
@@ -54,7 +54,7 @@ function gatewayReturning(principal: MessagePrincipal): MessageGateway {
|
|||||||
undefined as unknown as MessageService,
|
undefined as unknown as MessageService,
|
||||||
session,
|
session,
|
||||||
undefined as unknown as OutboxBus,
|
undefined as unknown as OutboxBus,
|
||||||
undefined as unknown as PresenceService,
|
undefined as unknown as PresencePort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ConnectedSocket,
|
ConnectedSocket,
|
||||||
MessageBody,
|
MessageBody,
|
||||||
@@ -16,7 +16,7 @@ import { logJson } from '../observability/logger';
|
|||||||
import { MessageService, type MessagePrincipal } from './message.service';
|
import { MessageService, type MessagePrincipal } from './message.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import { OutboxBus } from '../outbox/outbox.bus';
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
import { PresenceService } from '../notifications/presence.service';
|
import { PRESENCE_PORT, type PresencePort } from '../notifications/presence.port';
|
||||||
|
|
||||||
interface SocketState {
|
interface SocketState {
|
||||||
principal: MessagePrincipal;
|
principal: MessagePrincipal;
|
||||||
@@ -39,7 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
private readonly messages: MessageService,
|
private readonly messages: MessageService,
|
||||||
private readonly session: SessionVerifier,
|
private readonly session: SessionVerifier,
|
||||||
private readonly bus: OutboxBus,
|
private readonly bus: OutboxBus,
|
||||||
private readonly presence: PresenceService,
|
@Inject(PRESENCE_PORT) private readonly presence: PresencePort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -77,7 +77,8 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket): void {
|
handleDisconnect(client: Socket): void {
|
||||||
this.presence.clearSocket(client.id);
|
// Fire-and-forget: presence is best-effort and must not block the disconnect path.
|
||||||
|
void this.presence.clearSocket(client.id).catch((err) => this.logger.warn(`presence clear failed: ${(err as Error).message}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
|
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
|
||||||
@@ -85,7 +86,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
|
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
|
||||||
const state = client.data as SocketState | undefined;
|
const state = client.data as SocketState | undefined;
|
||||||
if (!state?.principal) return;
|
if (!state?.principal) return;
|
||||||
this.presence.setFocus(client.id, state.principal.userId, body?.threadId ?? null);
|
void this.presence
|
||||||
|
.setFocus(client.id, state.principal.userId, body?.threadId ?? null)
|
||||||
|
.catch((err) => this.logger.warn(`presence set failed: ${(err as Error).message}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('open_thread')
|
@SubscribeMessage('open_thread')
|
||||||
|
|||||||
@@ -3,10 +3,21 @@ import { MessageService } from './message.service';
|
|||||||
import { MessageGateway } from './message.gateway';
|
import { MessageGateway } from './message.gateway';
|
||||||
import { OutboxModule } from '../outbox/outbox.module';
|
import { OutboxModule } from '../outbox/outbox.module';
|
||||||
import { PresenceService } from '../notifications/presence.service';
|
import { PresenceService } from '../notifications/presence.service';
|
||||||
|
import { RedisPresenceService } from '../notifications/redis-presence.service';
|
||||||
|
import { PRESENCE_PORT } from '../notifications/presence.port';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PRESENCE_PORT is single-instance in-memory by default; with REDIS_URL set it's Redis-backed so
|
||||||
|
* "who is viewing what" is shared across replicas (mirrors the socket.io Redis adapter gating).
|
||||||
|
*/
|
||||||
|
const presenceProvider = {
|
||||||
|
provide: PRESENCE_PORT,
|
||||||
|
useFactory: () => (process.env.REDIS_URL ? new RedisPresenceService(process.env.REDIS_URL) : new PresenceService()),
|
||||||
|
};
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
providers: [MessageService, MessageGateway, PresenceService],
|
providers: [MessageService, MessageGateway, presenceProvider],
|
||||||
exports: [MessageService, PresenceService],
|
exports: [MessageService, PRESENCE_PORT],
|
||||||
})
|
})
|
||||||
export class MessageModule {}
|
export class MessageModule {}
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ async function sentEvents(): Promise<CloudEvent[]> {
|
|||||||
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
|
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
|
||||||
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
|
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
|
||||||
}
|
}
|
||||||
|
/** A synthetic interaction.normalized event (the shape ingest/mail emits) for an existing interaction. */
|
||||||
|
function normalizedEvent(interactionId: string, threadId: string, kind: string): CloudEvent {
|
||||||
|
return {
|
||||||
|
specversion: '1.0',
|
||||||
|
id: `evt_norm_${interactionId}`,
|
||||||
|
type: IIOS_EVENTS.interactionNormalized,
|
||||||
|
source: 'iios/ingest/test',
|
||||||
|
time: '2026-01-01T00:00:00.000Z',
|
||||||
|
insignia: { idempotencyKey: `norm:${interactionId}` },
|
||||||
|
data: { interactionId, threadId, kind },
|
||||||
|
} as CloudEvent;
|
||||||
|
}
|
||||||
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
|
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
|
||||||
const deliver = vi.fn().mockResolvedValue(deliverResult);
|
const deliver = vi.fn().mockResolvedValue(deliverResult);
|
||||||
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
|
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
|
||||||
@@ -51,7 +63,7 @@ describe('NotificationProjector', () => {
|
|||||||
await seedSub('bob');
|
await seedSub('bob');
|
||||||
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
|
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(deliver).toHaveBeenCalledOnce();
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||||
});
|
});
|
||||||
@@ -63,7 +75,7 @@ describe('NotificationProjector', () => {
|
|||||||
await seedSub('bob');
|
await seedSub('bob');
|
||||||
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(deliver).not.toHaveBeenCalled();
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +86,7 @@ describe('NotificationProjector', () => {
|
|||||||
await seedSub('bob');
|
await seedSub('bob');
|
||||||
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
|
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(deliver).toHaveBeenCalledOnce();
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +100,7 @@ describe('NotificationProjector', () => {
|
|||||||
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
|
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
const evs = await sentEvents();
|
const evs = await sentEvents();
|
||||||
await proj.onMessageSent(evs[evs.length - 1]!); // project the reply
|
await proj.onEvent(evs[evs.length - 1]!); // project the reply
|
||||||
expect(deliver).toHaveBeenCalledOnce();
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||||
});
|
});
|
||||||
@@ -102,7 +114,7 @@ describe('NotificationProjector', () => {
|
|||||||
const presence = new PresenceService();
|
const presence = new PresenceService();
|
||||||
presence.setFocus('sockB', 'bob', threadId);
|
presence.setFocus('sockB', 'bob', threadId);
|
||||||
const { proj, deliver } = makeProjector(presence);
|
const { proj, deliver } = makeProjector(presence);
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(deliver).not.toHaveBeenCalled();
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,7 +126,31 @@ describe('NotificationProjector', () => {
|
|||||||
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
|
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
|
||||||
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||||
const { proj, deliver } = makeProjector();
|
const { proj, deliver } = makeProjector();
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mail (interaction.normalized, kind EMAIL): notifies the recipient even in a group thread', async () => {
|
||||||
|
// A group thread would NOT notify bob on message.sent (proven above); the EMAIL branch always does.
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
const sent = await m.send(threadId, alice, { content: 'your invoice is attached' }, 'k1');
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
await proj.onEvent(normalizedEvent(sent.id, threadId, 'EMAIL'));
|
||||||
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
|
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interaction.normalized that is NOT mail (kind MESSAGE): does not notify', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
const sent = await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
await proj.onEvent(normalizedEvent(sent.id, threadId, 'MESSAGE'));
|
||||||
expect(deliver).not.toHaveBeenCalled();
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,7 +161,7 @@ describe('NotificationProjector', () => {
|
|||||||
await seedSub('bob');
|
await seedSub('bob');
|
||||||
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||||
const { proj } = makeProjector(new PresenceService(), 'gone');
|
const { proj } = makeProjector(new PresenceService(), 'gone');
|
||||||
await proj.onMessageSent((await sentEvents())[0]!);
|
await proj.onEvent((await sentEvents())[0]!);
|
||||||
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
|
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { OutboxBus } from '../outbox/outbox.bus';
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
import { DlqService } from '../outbox/dlq.service';
|
import { DlqService } from '../outbox/dlq.service';
|
||||||
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||||
import { PresenceService } from './presence.service';
|
import { PRESENCE_PORT, type PresencePort } from './presence.port';
|
||||||
import { NOTIFICATION_PORT, type NotificationPort } from './notification.port';
|
import { NOTIFICATION_PORT, type NotificationPort } from './notification.port';
|
||||||
|
|
||||||
interface MsgData {
|
interface MsgData {
|
||||||
@@ -14,14 +14,22 @@ interface MsgData {
|
|||||||
mentions?: string[];
|
mentions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NormalizedData {
|
||||||
|
interactionId: string;
|
||||||
|
threadId: string;
|
||||||
|
kind?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns `message.sent` into push notifications for absent recipients. Three gates:
|
* Turns messenger sends AND inbound mail into push notifications for absent recipients:
|
||||||
* 1. policy — DM always; group only if @mentioned or a reply to that recipient
|
* - `message.sent` → messenger policy (DM always; group only if @mentioned or reply-to-you)
|
||||||
* 2. presence — skip if the recipient is currently focused on that thread
|
* - `interaction.normalized` (kind EMAIL) → mail always notifies the recipient (a directed 1:1)
|
||||||
* 3. mute — skip if the recipient muted the thread
|
* Every candidate then passes two more gates before delivery:
|
||||||
|
* presence — skip if the recipient is currently focused on that thread
|
||||||
|
* mute — skip if the recipient muted the thread
|
||||||
* Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it.
|
* Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it.
|
||||||
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group is read
|
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group / mail is read
|
||||||
* from the opaque `membership` thread attribute here in the notification *policy*, not the kernel.
|
* from opaque thread attributes here in the notification *policy*, not the kernel.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationProjector implements OnModuleInit {
|
export class NotificationProjector implements OnModuleInit {
|
||||||
@@ -32,36 +40,63 @@ export class NotificationProjector implements OnModuleInit {
|
|||||||
private readonly bus: OutboxBus,
|
private readonly bus: OutboxBus,
|
||||||
private readonly dlq: DlqService,
|
private readonly dlq: DlqService,
|
||||||
private readonly cursor: ProjectionCursorService,
|
private readonly cursor: ProjectionCursorService,
|
||||||
private readonly presence: PresenceService,
|
@Inject(PRESENCE_PORT) private readonly presence: PresencePort,
|
||||||
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
|
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
this.dlq.registerHandler(this.consumer, (e) => this.onMessageSent(e));
|
this.dlq.registerHandler(this.consumer, (e) => this.onEvent(e));
|
||||||
this.bus.on(IIOS_EVENTS.messageSent, (p) =>
|
this.bus.on(IIOS_EVENTS.messageSent, (p) =>
|
||||||
void this.onMessageSent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
|
void this.onEvent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
|
||||||
|
);
|
||||||
|
// Mail (external email + app-to-app) lands via ingest, which emits interaction.normalized — not
|
||||||
|
// message.sent — so subscribe here too and filter to EMAIL inside apply.
|
||||||
|
this.bus.on(IIOS_EVENTS.interactionNormalized, (p) =>
|
||||||
|
void this.onEvent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onMessageSent(event: CloudEvent): Promise<void> {
|
async onEvent(event: CloudEvent): Promise<void> {
|
||||||
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
|
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
|
||||||
await this.apply(event);
|
await this.apply(event);
|
||||||
await this.cursor.advance(this.consumer, event);
|
await this.cursor.advance(this.consumer, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async apply(event: CloudEvent): Promise<void> {
|
private async apply(event: CloudEvent): Promise<void> {
|
||||||
|
if (event.type === IIOS_EVENTS.messageSent) return this.applyMessage(event);
|
||||||
|
if (event.type === IIOS_EVENTS.interactionNormalized) return this.applyMail(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messenger send: DM always notifies; group only on @mention or reply-to-you. */
|
||||||
|
private async applyMessage(event: CloudEvent): Promise<void> {
|
||||||
const data = event.data as MsgData;
|
const data = event.data as MsgData;
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
|
||||||
if (!thread) return;
|
if (!thread) return;
|
||||||
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
await this.notify(data.threadId, data.interactionId, data.senderActorId, data.mentions ?? [], membership === 'dm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inbound mail (kind EMAIL): a directed message — always notify the non-sender recipient(s). */
|
||||||
|
private async applyMail(event: CloudEvent): Promise<void> {
|
||||||
|
const data = event.data as NormalizedData;
|
||||||
|
if (data.kind !== 'EMAIL') return; // other normalized interactions aren't mail — ignore
|
||||||
|
const sender = await this.prisma.iiosInteraction.findUnique({ where: { id: data.interactionId }, select: { actorId: true } });
|
||||||
|
if (!sender?.actorId) return;
|
||||||
|
await this.notify(data.threadId, data.interactionId, sender.actorId, [], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared fan-out: load the message, then for every participant but the sender apply the policy
|
||||||
|
* (alwaysNotify OR @mentioned OR replied-to-you), presence, and mute gates before delivering.
|
||||||
|
*/
|
||||||
|
private async notify(threadId: string, interactionId: string, senderActorId: string, mentions: string[], alwaysNotify: boolean): Promise<void> {
|
||||||
|
const data = { threadId, interactionId, senderActorId };
|
||||||
const interaction = await this.prisma.iiosInteraction.findUnique({
|
const interaction = await this.prisma.iiosInteraction.findUnique({
|
||||||
where: { id: data.interactionId },
|
where: { id: data.interactionId },
|
||||||
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
||||||
});
|
});
|
||||||
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
|
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
|
||||||
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
|
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
|
||||||
const mentions = data.mentions ?? [];
|
|
||||||
|
|
||||||
// Parent author (for reply-to-you) — one lookup.
|
// Parent author (for reply-to-you) — one lookup.
|
||||||
let parentAuthorActorId: string | null = null;
|
let parentAuthorActorId: string | null = null;
|
||||||
@@ -85,10 +120,10 @@ export class NotificationProjector implements OnModuleInit {
|
|||||||
// gate 1 — policy
|
// gate 1 — policy
|
||||||
const mentioned = userId != null && mentions.includes(userId);
|
const mentioned = userId != null && mentions.includes(userId);
|
||||||
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
|
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
|
||||||
if (!(membership === 'dm' || mentioned || repliedToMe)) continue;
|
if (!(alwaysNotify || mentioned || repliedToMe)) continue;
|
||||||
|
|
||||||
// gate 2 — presence
|
// gate 2 — presence
|
||||||
if (userId && this.presence.isViewing(userId, data.threadId)) continue;
|
if (userId && (await this.presence.isViewing(userId, data.threadId))) continue;
|
||||||
|
|
||||||
// gate 3 — mute
|
// gate 3 — mute
|
||||||
if (p.muted) continue;
|
if (p.muted) continue;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Presence seam: tracks which thread each socket has in the foreground so the notification
|
||||||
|
* projector can suppress push for a thread the recipient is actively viewing. Async so it can be
|
||||||
|
* backed by Redis across replicas (prod) or an in-memory Map on a single instance (dev).
|
||||||
|
*/
|
||||||
|
export interface PresencePort {
|
||||||
|
/** Record a socket's foregrounded thread (null when the window is blurred / no thread open). */
|
||||||
|
setFocus(socketId: string, userId: string, threadId: string | null): Promise<void>;
|
||||||
|
/** Forget everything about a socket (on disconnect). */
|
||||||
|
clearSocket(socketId: string): Promise<void>;
|
||||||
|
/** True if ANY of the user's sockets currently has this thread in the foreground. */
|
||||||
|
isViewing(userId: string, threadId: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESENCE_PORT = Symbol('PRESENCE_PORT');
|
||||||
@@ -2,23 +2,23 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { PresenceService } from './presence.service';
|
import { PresenceService } from './presence.service';
|
||||||
|
|
||||||
describe('PresenceService', () => {
|
describe('PresenceService', () => {
|
||||||
it('reports viewing only for the focused thread, and clears on disconnect', () => {
|
it('reports viewing only for the focused thread, and clears on disconnect', async () => {
|
||||||
const p = new PresenceService();
|
const p = new PresenceService();
|
||||||
expect(p.isViewing('alice', 'T1')).toBe(false);
|
expect(await p.isViewing('alice', 'T1')).toBe(false);
|
||||||
p.setFocus('sock1', 'alice', 'T1');
|
await p.setFocus('sock1', 'alice', 'T1');
|
||||||
expect(p.isViewing('alice', 'T1')).toBe(true);
|
expect(await p.isViewing('alice', 'T1')).toBe(true);
|
||||||
expect(p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
|
expect(await p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
|
||||||
p.setFocus('sock1', 'alice', 'T2'); // moved focus
|
await p.setFocus('sock1', 'alice', 'T2'); // moved focus
|
||||||
expect(p.isViewing('alice', 'T1')).toBe(false);
|
expect(await p.isViewing('alice', 'T1')).toBe(false);
|
||||||
expect(p.isViewing('alice', 'T2')).toBe(true);
|
expect(await p.isViewing('alice', 'T2')).toBe(true);
|
||||||
p.clearSocket('sock1');
|
await p.clearSocket('sock1');
|
||||||
expect(p.isViewing('alice', 'T2')).toBe(false);
|
expect(await p.isViewing('alice', 'T2')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('any of the actor’s sockets counts as viewing', () => {
|
it('any of the actor’s sockets counts as viewing', async () => {
|
||||||
const p = new PresenceService();
|
const p = new PresenceService();
|
||||||
p.setFocus('sockA', 'bob', 'T9');
|
await p.setFocus('sockA', 'bob', 'T9');
|
||||||
p.setFocus('sockB', 'bob', null); // a second tab, no focus
|
await p.setFocus('sockB', 'bob', null); // a second tab, no focus
|
||||||
expect(p.isViewing('bob', 'T9')).toBe(true);
|
expect(await p.isViewing('bob', 'T9')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { PresencePort } from './presence.port';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory focus tracker (single instance). Keyed on userId (the stable externalId the
|
* In-memory focus tracker (single instance). Keyed on userId (the stable externalId the
|
||||||
* gateway has as principal.userId). NOTE: room membership ≠ viewing — the sidebar joins
|
* gateway has as principal.userId). NOTE: room membership ≠ viewing — the sidebar joins
|
||||||
* every thread room for live updates, so presence uses an explicit `focus_thread` signal.
|
* every thread room for live updates, so presence uses an explicit `focus_thread` signal.
|
||||||
* Prod (multi-replica): back this with Redis.
|
* Multi-replica prod uses {@link RedisPresenceService} instead (wired when REDIS_URL is set).
|
||||||
|
*
|
||||||
|
* Methods are async to satisfy the PresencePort seam; the map is mutated synchronously, so an
|
||||||
|
* un-awaited setFocus is still visible to an immediately-following isViewing.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PresenceService {
|
export class PresenceService implements PresencePort {
|
||||||
private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus
|
private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus
|
||||||
|
|
||||||
setFocus(socketId: string, userId: string, threadId: string | null): void {
|
async setFocus(socketId: string, userId: string, threadId: string | null): Promise<void> {
|
||||||
this.focus.set(socketId, { userId, threadId });
|
this.focus.set(socketId, { userId, threadId });
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSocket(socketId: string): void {
|
async clearSocket(socketId: string): Promise<void> {
|
||||||
this.focus.delete(socketId);
|
this.focus.delete(socketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
isViewing(userId: string, threadId: string): boolean {
|
async isViewing(userId: string, threadId: string): Promise<boolean> {
|
||||||
for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true;
|
for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import type { PresencePort } from './presence.port';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis-backed presence for multi-replica prod: a socket connected to replica A and a message
|
||||||
|
* projected on replica B must share the same "who is viewing what" view, which an in-memory Map
|
||||||
|
* cannot provide. Wired (in place of {@link PresenceService}) only when REDIS_URL is set.
|
||||||
|
*
|
||||||
|
* Layout (per user, tiny):
|
||||||
|
* sock:{socketId} -> userId (so clearSocket can find the user), EX TTL
|
||||||
|
* user:{userId} -> HASH socketId=threadId, EX TTL
|
||||||
|
* isViewing = does the user hash hold this threadId for any socket. Keys carry a TTL so a crashed
|
||||||
|
* replica's entries self-heal; expiry errs toward "not viewing" (push is sent), the safe default.
|
||||||
|
*/
|
||||||
|
export class RedisPresenceService implements PresencePort, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RedisPresenceService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private static readonly TTL_SECONDS = 300;
|
||||||
|
private static readonly PREFIX = 'iios:presence';
|
||||||
|
|
||||||
|
constructor(url: string, client?: Redis) {
|
||||||
|
this.redis = client ?? new Redis(url, { maxRetriesPerRequest: null });
|
||||||
|
this.redis.on('error', (err) => this.logger.error(`redis presence error: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sockKey(socketId: string): string {
|
||||||
|
return `${RedisPresenceService.PREFIX}:sock:${socketId}`;
|
||||||
|
}
|
||||||
|
private userKey(userId: string): string {
|
||||||
|
return `${RedisPresenceService.PREFIX}:user:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFocus(socketId: string, userId: string, threadId: string | null): Promise<void> {
|
||||||
|
const ttl = RedisPresenceService.TTL_SECONDS;
|
||||||
|
const sock = this.sockKey(socketId);
|
||||||
|
const user = this.userKey(userId);
|
||||||
|
const pipe = this.redis.multi().set(sock, userId, 'EX', ttl);
|
||||||
|
if (threadId === null) {
|
||||||
|
// Blurred / no thread open — the socket stays connected but is viewing nothing.
|
||||||
|
pipe.hdel(user, socketId);
|
||||||
|
} else {
|
||||||
|
pipe.hset(user, socketId, threadId).expire(user, ttl);
|
||||||
|
}
|
||||||
|
await pipe.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearSocket(socketId: string): Promise<void> {
|
||||||
|
const sock = this.sockKey(socketId);
|
||||||
|
const userId = await this.redis.get(sock);
|
||||||
|
const pipe = this.redis.multi().del(sock);
|
||||||
|
if (userId) pipe.hdel(this.userKey(userId), socketId);
|
||||||
|
await pipe.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async isViewing(userId: string, threadId: string): Promise<boolean> {
|
||||||
|
const threads = await this.redis.hvals(this.userKey(userId));
|
||||||
|
return threads.includes(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.redis.quit().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user