Phase 6.5: notifications service + in-app toasts; in-app voice/video messages; SVG icons

Notifications (web push now, Android FCM ready, iOS prepared):
- device_tokens / notification_settings / conversation_mutes (migration 006)
- REST: register/list/unregister devices, get/update settings (quiet hours,
  timezone, enable), mute/unmute conversation, VAPID public key
- BullMQ queue: backend enqueues a job per new message; a dedicated stateless
  `notifications` worker service drains it
- Delivery gating: skip sender, muted chats, disabled users, quiet hours, and
  ONLINE users (they get the message live + an in-app toast) — offline → push
- Providers behind one interface: Web Push (VAPID/web-push) + FCM (firebase-admin,
  HTTP v1; Android + iOS via the same path). Dead tokens (404/410/unregistered)
  auto-retired; transient failures recorded
- Web: service worker (push display + tap-to-open the originating chat), push
  registration/unregistration, in-app toast stack, deep-link via ?conversation=

In-app voice & video messages: MediaRecorder capture in the composer (mic/video),
live preview + timer, upload via the existing presigned-media path.

No emojis anywhere: replaced all glyphs with inline SVG icons (shared/ui) so the
UI renders identically across web/desktop/mobile; notification text is plain.

Chores: dedupe ioredis (BullMQ) + pin uuid>=11.1.1 (audit clean) via pnpm overrides;
reusable Centrifugo client factory for the worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
This commit is contained in:
Заид Омар Медхат | Zaid Omar Medhat 2026-07-10 19:05:39 +05:00
parent db5c1610b3
commit 47fbf861ee
44 changed files with 3690 additions and 82 deletions

View file

@ -61,6 +61,21 @@ MINIO_PUBLIC_URL=http://localhost:9000
# so presigning never makes a network region-lookup call. # so presigning never makes a network region-lookup call.
MINIO_REGION=us-east-1 MINIO_REGION=us-east-1
# --- notifications (Phase 6.5) ---
# Web Push (VAPID). Generate a keypair once with:
# node -e "console.log(require('web-push').generateVAPIDKeys())"
# The public key is safe to expose to the browser; keep the private key secret.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@altricade.com
# Firebase Cloud Messaging (Android now, iOS later — same code path). Point this
# at the service-account JSON mounted into the worker; leave empty to disable FCM.
FCM_SERVICE_ACCOUNT_FILE=
# iOS push is prepared but off until an APNs key is uploaded to Firebase.
APNS_ENABLED=false
# BullMQ queue name for the notifications worker.
NOTIFICATIONS_QUEUE=notifications
# --- nginx (public entrypoint) --- # --- nginx (public entrypoint) ---
NGINX_HTTP_PORT=8080 NGINX_HTTP_PORT=8080

View file

@ -30,6 +30,17 @@ services:
ports: ports:
- '${BACKEND_PORT:-4000}:4000' - '${BACKEND_PORT:-4000}:4000'
notifications:
build:
context: .
dockerfile: packages/backend/Dockerfile
target: dev
command: ['pnpm', 'dev:worker']
volumes:
- ./packages/backend/src:/repo/packages/backend/src
- ./packages/core/src:/repo/packages/core/src
- ./infra/secrets:/app/secrets:ro
nginx: nginx:
ports: ports:
- '${NGINX_HTTP_PORT:-8080}:80' - '${NGINX_HTTP_PORT:-8080}:80'

View file

@ -17,6 +17,9 @@ services:
backend: backend:
restart: unless-stopped restart: unless-stopped
notifications:
restart: unless-stopped
nginx: nginx:
restart: unless-stopped restart: unless-stopped
ports: ports:

View file

@ -114,6 +114,27 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
networks: [altricade] networks: [altricade]
# Notifications worker — drains the BullMQ queue and sends web/FCM pushes.
# Reuses the backend image (tsup emits dist/worker.js). The infra/secrets dir
# is mounted read-only so a Firebase service-account JSON can be provided
# without rebuilding; FCM stays disabled until FCM_SERVICE_ACCOUNT_FILE is set.
notifications:
build:
context: .
dockerfile: packages/backend/Dockerfile
command: ['node', 'dist/worker.js']
env_file: .env
volumes:
- ./infra/secrets:/app/secrets:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
networks: [altricade]
nginx: nginx:
image: nginx:1.27-alpine image: nginx:1.27-alpine
volumes: volumes:

4
infra/secrets/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Never commit real credentials.
*
!.gitignore
!README.md

16
infra/secrets/README.md Normal file
View file

@ -0,0 +1,16 @@
# Secrets (git-ignored)
Drop credential files here. This directory is mounted read-only into the
`notifications` worker at `/app/secrets`.
## Firebase Cloud Messaging (Android push, and iOS later)
1. Firebase Console → ⚙ Project settings → **Service accounts**
**Generate new private key** → download the JSON.
2. Save it here as `fcm-service-account.json`.
3. In `.env`, set:
`FCM_SERVICE_ACCOUNT_FILE=/app/secrets/fcm-service-account.json`
4. Recreate the worker: `docker compose up -d --force-recreate notifications`
Until this is set, native (FCM) push is disabled and the worker logs
"fcm disabled (no service account configured)". Web Push works without it.

View file

@ -27,7 +27,9 @@
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"esbuild": ">=0.28.1" "esbuild": ">=0.28.1",
"ioredis": "^5.11.1",
"uuid": "^11.1.1"
} }
} }
} }

View file

@ -0,0 +1,76 @@
// Phase 6.5 — notifications: device registry, per-user settings, per-chat mutes.
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
// Push credentials, one row per device/subscription. `token` is the stable,
// globally-unique identity: the FCM registration token for native, or the
// Web Push endpoint URL for web (p256dh/auth hold the web subscription keys).
pgm.createTable('device_tokens', {
id: { type: 'uuid', notNull: true, default: pgm.func('gen_random_uuid()'), primaryKey: true },
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
platform: { type: 'text', notNull: true },
token: { type: 'text', notNull: true },
web_p256dh: { type: 'text' },
web_auth: { type: 'text' },
failure_count: { type: 'integer', notNull: true, default: 0 },
last_success_at: { type: 'timestamptz' },
disabled_at: { type: 'timestamptz' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('device_tokens', 'device_tokens_platform_check', {
check: "platform IN ('web', 'android', 'ios')",
});
// A token identifies exactly one device; re-registering rebinds it to its user.
pgm.addConstraint('device_tokens', 'device_tokens_token_unique', { unique: ['token'] });
pgm.createIndex('device_tokens', 'user_id', {
name: 'device_tokens_active_by_user',
where: 'disabled_at IS NULL',
});
// Per-user notification preferences. Quiet hours are minutes-since-midnight in
// the user's IANA timezone; NULL start/end means "no quiet hours".
pgm.createTable('notification_settings', {
user_id: {
type: 'uuid',
notNull: true,
references: 'users',
onDelete: 'CASCADE',
primaryKey: true,
},
enabled: { type: 'boolean', notNull: true, default: true },
quiet_hours_start: { type: 'smallint' },
quiet_hours_end: { type: 'smallint' },
timezone: { type: 'text', notNull: true, default: 'UTC' },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('notification_settings', 'notification_settings_quiet_range_check', {
check: `
(quiet_hours_start IS NULL OR (quiet_hours_start >= 0 AND quiet_hours_start <= 1439))
AND (quiet_hours_end IS NULL OR (quiet_hours_end >= 0 AND quiet_hours_end <= 1439))
`,
});
// Per-conversation mute. muted_until NULL means muted indefinitely.
pgm.createTable('conversation_mutes', {
conversation_id: {
type: 'uuid',
notNull: true,
references: 'conversations',
onDelete: 'CASCADE',
},
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
muted_until: { type: 'timestamptz' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('conversation_mutes', 'conversation_mutes_pkey', {
primaryKey: ['conversation_id', 'user_id'],
});
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
pgm.dropTable('conversation_mutes');
pgm.dropTable('notification_settings');
pgm.dropTable('device_tokens');
};

View file

@ -5,8 +5,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/server.ts", "dev": "tsx watch src/server.ts",
"dev:worker": "tsx watch src/worker.ts",
"build": "tsup", "build": "tsup",
"start": "node dist/server.js", "start": "node dist/server.js",
"start:worker": "node dist/worker.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"migrate:up": "node-pg-migrate up", "migrate:up": "node-pg-migrate up",
@ -21,18 +23,22 @@
"@fastify/swagger-ui": "^6.1.0", "@fastify/swagger-ui": "^6.1.0",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"bullmq": "^5.80.0",
"fastify": "^5.10.0", "fastify": "^5.10.0",
"fastify-plugin": "^6.0.0", "fastify-plugin": "^6.0.0",
"firebase-admin": "^14.1.0",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jose": "^6.2.3", "jose": "^6.2.3",
"kysely": "^0.29.3", "kysely": "^0.29.3",
"minio": "^8.0.7", "minio": "^8.0.7",
"node-pg-migrate": "^8.0.4", "node-pg-migrate": "^8.0.4",
"pg": "^8.22.0" "pg": "^8.22.0",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/web-push": "^3.6.4",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"tsx": "^4.23.0", "tsx": "^4.23.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"

View file

@ -26,6 +26,13 @@ import { createMessagesRepository, createMessagesService, messagesRoutes } from
import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts'; import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts';
import { createPresenceService, presenceRoutes } from './modules/presence'; import { createPresenceService, presenceRoutes } from './modules/presence';
import { createMediaService, mediaRoutes } from './modules/media'; import { createMediaService, mediaRoutes } from './modules/media';
import {
createNotificationsRepository,
createNotificationsService,
createNotificationQueue,
createBullConnection,
notificationsRoutes,
} from './modules/notifications';
import { realtimeRoutes } from './modules/realtime'; import { realtimeRoutes } from './modules/realtime';
import type { Publisher } from './shared/publisher'; import type { Publisher } from './shared/publisher';
@ -98,6 +105,25 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
}); });
app.decorate('mediaService', mediaService); app.decorate('mediaService', mediaService);
// Notifications: a Redis-backed BullMQ queue receives a job per new message;
// the separate `notifications` worker service drains it and sends the pushes.
const notificationsRepository = createNotificationsRepository(app.db);
const bullConnection = createBullConnection(config.redisUrl);
const notificationQueue = createNotificationQueue(config.notifications.queueName, bullConnection);
app.addHook('onClose', async () => {
await notificationQueue.close();
await bullConnection.quit();
});
app.decorate(
'notificationsService',
createNotificationsService({
repo: notificationsRepository,
webPushPublicKey: config.notifications.webPush?.publicKey ?? '',
isMember: (conversationId, userId) =>
conversationsRepository.isMember(conversationId, userId),
}),
);
app.decorate('usersService', createUsersService({ users: usersRepository, publish })); app.decorate('usersService', createUsersService({ users: usersRepository, publish }));
app.decorate( app.decorate(
'authService', 'authService',
@ -130,6 +156,17 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
conversations: conversationsRepository, conversations: conversationsRepository,
deliver, deliver,
mediaDownloadUrl: (objectKey) => mediaService.downloadUrl(objectKey), mediaDownloadUrl: (objectKey) => mediaService.downloadUrl(objectKey),
notify: (message) => {
void notificationQueue
.enqueueMessage({
conversationId: message.conversationId,
messageId: message.id,
senderId: message.senderId,
})
.catch((error: unknown) => {
app.log.error(error, 'failed to enqueue notification job');
});
},
}), }),
); );
app.decorate( app.decorate(
@ -156,6 +193,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
await app.register(contactsRoutes); await app.register(contactsRoutes);
await app.register(presenceRoutes); await app.register(presenceRoutes);
await app.register(mediaRoutes); await app.register(mediaRoutes);
await app.register(notificationsRoutes);
await app.register(realtimeRoutes); await app.register(realtimeRoutes);
app.log.info(`core wired — example channel: ${conversationChannel('demo')}`); app.log.info(`core wired — example channel: ${conversationChannel('demo')}`);

View file

@ -31,6 +31,23 @@ export interface AuthConfig {
refreshTtlSeconds: number; refreshTtlSeconds: number;
} }
export interface WebPushConfig {
publicKey: string;
privateKey: string;
/** VAPID contact — a mailto: or https: URL. */
subject: string;
}
export interface NotificationsConfig {
/** null when VAPID keys are unset → Web Push disabled. */
webPush: WebPushConfig | null;
/** Path to the Firebase service-account JSON; null → native (FCM) push disabled. */
fcmServiceAccountFile: string | null;
/** APNs is prepared but never enabled until Apple credentials exist (routes via FCM). */
apnsEnabled: boolean;
queueName: string;
}
export interface AppConfig { export interface AppConfig {
nodeEnv: string; nodeEnv: string;
port: number; port: number;
@ -40,6 +57,7 @@ export interface AppConfig {
minio: MinioConfig; minio: MinioConfig;
centrifugo: CentrifugoConfig; centrifugo: CentrifugoConfig;
auth: AuthConfig; auth: AuthConfig;
notifications: NotificationsConfig;
} }
const required = (name: string): string => { const required = (name: string): string => {
@ -68,6 +86,22 @@ const parsePort = (name: string, raw: string): number => {
const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true'; const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true';
const buildNotificationsConfig = (): NotificationsConfig => {
const publicKey = optional('VAPID_PUBLIC_KEY', '');
const privateKey = optional('VAPID_PRIVATE_KEY', '');
const webPush =
publicKey !== '' && privateKey !== ''
? { publicKey, privateKey, subject: optional('VAPID_SUBJECT', 'mailto:admin@altricade.com') }
: null;
const fcmFile = optional('FCM_SERVICE_ACCOUNT_FILE', '');
return {
webPush,
fcmServiceAccountFile: fcmFile === '' ? null : fcmFile,
apnsEnabled: parseBoolean(optional('APNS_ENABLED', 'false')),
queueName: optional('NOTIFICATIONS_QUEUE', 'notifications'),
};
};
// Parse a duration like "900", "15s", "15m", "1h", "30d" into seconds. // Parse a duration like "900", "15s", "15m", "1h", "30d" into seconds.
const DURATION_UNITS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 }; const DURATION_UNITS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
@ -115,4 +149,5 @@ export const loadConfig = (): AppConfig => ({
accessTtlSeconds: parseDurationSeconds('ACCESS_TOKEN_TTL', optional('ACCESS_TOKEN_TTL', '15m')), accessTtlSeconds: parseDurationSeconds('ACCESS_TOKEN_TTL', optional('ACCESS_TOKEN_TTL', '15m')),
refreshTtlSeconds: parseDurationSeconds('REFRESH_TOKEN_TTL', optional('REFRESH_TOKEN_TTL', '30d')), refreshTtlSeconds: parseDurationSeconds('REFRESH_TOKEN_TTL', optional('REFRESH_TOKEN_TTL', '30d')),
}, },
notifications: buildNotificationsConfig(),
}); });

View file

@ -84,6 +84,36 @@ export interface ReadStateTable {
updated_at: ColumnType<Date, Date | undefined, Date | undefined>; updated_at: ColumnType<Date, Date | undefined, Date | undefined>;
} }
export interface DeviceTokensTable {
id: Generated<string>;
user_id: string;
platform: string;
token: string;
web_p256dh: string | null;
web_auth: string | null;
failure_count: Generated<number>;
last_success_at: Date | null;
disabled_at: Date | null;
created_at: Generated<Date>;
updated_at: ColumnType<Date, Date | undefined, Date | undefined>;
}
export interface NotificationSettingsTable {
user_id: string;
enabled: Generated<boolean>;
quiet_hours_start: number | null;
quiet_hours_end: number | null;
timezone: Generated<string>;
updated_at: ColumnType<Date, Date | undefined, Date | undefined>;
}
export interface ConversationMutesTable {
conversation_id: string;
user_id: string;
muted_until: Date | null;
created_at: Generated<Date>;
}
export interface Database { export interface Database {
users: UsersTable; users: UsersTable;
refresh_tokens: RefreshTokensTable; refresh_tokens: RefreshTokensTable;
@ -93,4 +123,7 @@ export interface Database {
contacts: ContactsTable; contacts: ContactsTable;
reactions: ReactionsTable; reactions: ReactionsTable;
read_state: ReadStateTable; read_state: ReadStateTable;
device_tokens: DeviceTokensTable;
notification_settings: NotificationSettingsTable;
conversation_mutes: ConversationMutesTable;
} }

View file

@ -18,6 +18,8 @@ export interface MessagesServiceDeps {
conversations: ConversationsRepository; conversations: ConversationsRepository;
deliver: Deliver; deliver: Deliver;
mediaDownloadUrl: (objectKey: string) => Promise<string>; mediaDownloadUrl: (objectKey: string) => Promise<string>;
/** Fire-and-forget push-notification hook, called for each newly-created message. */
notify: (message: Message) => void;
} }
export interface SentMessage { export interface SentMessage {
@ -56,7 +58,7 @@ export interface MessagesService {
} }
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => { export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
const { messages, conversations, deliver, mediaDownloadUrl } = deps; const { messages, conversations, deliver, mediaDownloadUrl, notify } = deps;
const assertMember = async (conversationId: string, userId: string): Promise<void> => { const assertMember = async (conversationId: string, userId: string): Promise<void> => {
if (!(await conversations.isMember(conversationId, userId))) { if (!(await conversations.isMember(conversationId, userId))) {
@ -110,6 +112,8 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
await conversations.touchLastMessage(conversationId); await conversations.touchLastMessage(conversationId);
const event: MessageNewEvent = { type: EventType.MessageNew, message }; const event: MessageNewEvent = { type: EventType.MessageNew, message };
await deliver(conversationId, event); await deliver(conversationId, event);
// Enqueue a push job (offline recipients only; online ones get it live).
notify(message);
} }
return { message, created: inserted !== undefined }; return { message, created: inserted !== undefined };
}, },

View file

@ -0,0 +1,15 @@
export { createNotificationsRepository } from './notifications.repository';
export type { NotificationsRepository } from './notifications.repository';
export { createNotificationsService } from './notifications.service';
export type { NotificationsService } from './notifications.service';
export { createDispatcher } from './notifications.dispatch';
export type { Dispatcher, DispatcherDeps } from './notifications.dispatch';
export {
createNotificationQueue,
createBullConnection,
MESSAGE_JOB,
} from './notifications.queue';
export type { NotificationQueue, MessageJobData } from './notifications.queue';
export { createWebPushProvider, createFcmProvider } from './providers';
export type { PushProvider } from './providers';
export { notificationsRoutes } from './notifications.routes';

View file

@ -0,0 +1,173 @@
import type { Message, MediaKind, PushPayload } from '@altricade/core';
import type { MessagesRepository } from '../messages';
import { toMessage } from '../messages';
import type { ConversationsRepository, ConversationRow } from '../conversations';
import type { NotificationsRepository, SettingsRow } from './notifications.repository';
import type { PushProvider } from './providers';
import type { MessageJobData } from './notifications.queue';
export interface DispatcherDeps {
repo: NotificationsRepository;
messages: Pick<MessagesRepository, 'getWithSenderById'>;
conversations: Pick<ConversationsRepository, 'findById' | 'listMembers'>;
/** True when the user has a live realtime connection → in-app handles it, no push. */
isOnline: (userId: string) => Promise<boolean>;
/** Push providers keyed by platform ('web' | 'android' | 'ios'). */
providers: Map<string, PushProvider>;
log: (message: string) => void;
}
export interface Dispatcher {
handleMessageJob(data: MessageJobData): Promise<void>;
}
const BODY_MAX = 140;
const mediaPlaceholder = (kind: MediaKind): string => {
switch (kind) {
case 'image':
return 'Photo';
case 'video':
return 'Video';
case 'voice':
return 'Voice message';
default:
return 'File';
}
};
const preview = (message: Message): string => {
if (message.content.length > 0) {
return message.content.length > BODY_MAX
? `${message.content.slice(0, BODY_MAX)}`
: message.content;
}
if (message.media !== null) {
return mediaPlaceholder(message.media.kind);
}
return 'New message';
};
const buildPayload = (message: Message, conversation: ConversationRow): PushPayload => {
const text = preview(message);
const isGroup = conversation.type === 'group';
return {
kind: 'message',
conversationId: message.conversationId,
messageId: message.id,
senderId: message.senderId,
title: isGroup ? (conversation.title ?? 'Group') : message.sender.displayName,
body: isGroup ? `${message.sender.displayName}: ${text}` : text,
};
};
// Minutes since local midnight in the given IANA timezone, or null on a bad zone.
const localMinutes = (timezone: string): number | null => {
try {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(new Date());
const hour = parts.find((part) => part.type === 'hour')?.value;
const minute = parts.find((part) => part.type === 'minute')?.value;
if (hour === undefined || minute === undefined) {
return null;
}
const hours = Number.parseInt(hour, 10);
const minutes = Number.parseInt(minute, 10);
if (Number.isNaN(hours) || Number.isNaN(minutes)) {
return null;
}
return (hours % 24) * 60 + minutes;
} catch {
return null;
}
};
const inQuietHours = (settings: SettingsRow | undefined): boolean => {
if (settings === undefined) {
return false;
}
const start = settings.quiet_hours_start;
const end = settings.quiet_hours_end;
if (start === null || end === null || start === end) {
return false;
}
const now = localMinutes(settings.timezone);
if (now === null) {
return false;
}
// Same-day window vs. an overnight window that wraps past midnight.
return start < end ? now >= start && now < end : now >= start || now < end;
};
export const createDispatcher = (deps: DispatcherDeps): Dispatcher => {
const { repo, messages, conversations, isOnline, providers, log } = deps;
const deliverToUser = async (userId: string, payload: PushPayload): Promise<void> => {
const devices = await repo.activeDevicesFor(userId);
for (const device of devices) {
const provider = providers.get(device.platform);
if (provider === undefined) {
continue;
}
const result = await provider.send(device, payload);
if (result.ok) {
await repo.recordSuccess(device.id);
} else if (result.disable) {
await repo.disableDevice(device.id);
log(`disabled dead ${device.platform} token ${device.id}`);
} else {
await repo.recordFailure(device.id);
}
}
};
return {
handleMessageJob: async (data) => {
const row = await messages.getWithSenderById(data.messageId);
if (row === undefined) {
return;
}
if (row.deleted_at !== null) {
return;
}
const message = toMessage(row);
const conversation = await conversations.findById(data.conversationId);
if (conversation === undefined) {
return;
}
const members = await conversations.listMembers(data.conversationId);
const recipients = members
.map((member) => member.user_id)
.filter((userId) => userId !== data.senderId);
if (recipients.length === 0) {
return;
}
const settingsMap = await repo.getSettingsFor(recipients);
const mutedSet = await repo.mutedUserIds(data.conversationId, recipients);
const payload = buildPayload(message, conversation);
for (const userId of recipients) {
const settings = settingsMap.get(userId);
if (settings !== undefined && !settings.enabled) {
continue;
}
if (mutedSet.has(userId)) {
continue;
}
if (inQuietHours(settings)) {
continue;
}
// Online users receive the message live + an in-app toast — skip push.
if (await isOnline(userId)) {
continue;
}
await deliverToUser(userId, payload);
}
},
};
};

View file

@ -0,0 +1,45 @@
import { Queue } from 'bullmq';
import { Redis } from 'ioredis';
// A new message that may warrant a push. Only ids travel through the queue; the
// worker re-loads fresh state so nothing goes stale between enqueue and delivery.
export const MESSAGE_JOB = 'message';
export interface MessageJobData {
conversationId: string;
messageId: string;
senderId: string;
}
export interface NotificationQueue {
enqueueMessage(data: MessageJobData): Promise<void>;
close(): Promise<void>;
}
// BullMQ requires a dedicated connection with retry-per-request disabled.
export const createBullConnection = (redisUrl: string): Redis =>
new Redis(redisUrl, { maxRetriesPerRequest: null });
export const createNotificationQueue = (
queueName: string,
connection: Redis,
): NotificationQueue => {
const queue = new Queue<MessageJobData>(queueName, {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
return {
enqueueMessage: async (data) => {
await queue.add(MESSAGE_JOB, data);
},
close: async () => {
await queue.close();
},
};
};

View file

@ -0,0 +1,191 @@
import type { Kysely, Selectable } from 'kysely';
import type { Database, NotificationSettingsTable } from '../../db/schema';
export type SettingsRow = Selectable<NotificationSettingsTable>;
// Row shape used when actually sending (carries the push credentials).
export interface SendableDevice {
id: string;
platform: string;
token: string;
web_p256dh: string | null;
web_auth: string | null;
}
export interface DeviceApiRow {
id: string;
platform: string;
created_at: Date;
last_success_at: Date | null;
}
export interface UpsertDeviceInput {
userId: string;
platform: string;
token: string;
p256dh: string | null;
auth: string | null;
}
// Snake-cased columns the settings upsert may change; camel→snake happens in the service.
export interface SettingsPatch {
enabled?: boolean;
quiet_hours_start?: number | null;
quiet_hours_end?: number | null;
timezone?: string;
}
export interface NotificationsRepository {
upsertDevice(input: UpsertDeviceInput): Promise<void>;
deleteDevice(userId: string, token: string): Promise<void>;
listDevicesForApi(userId: string): Promise<DeviceApiRow[]>;
activeDevicesFor(userId: string): Promise<SendableDevice[]>;
disableDevice(id: string): Promise<void>;
recordSuccess(id: string): Promise<void>;
recordFailure(id: string): Promise<void>;
getSettings(userId: string): Promise<SettingsRow | undefined>;
getSettingsFor(userIds: string[]): Promise<Map<string, SettingsRow>>;
upsertSettings(userId: string, patch: SettingsPatch): Promise<SettingsRow>;
mute(conversationId: string, userId: string, mutedUntil: Date | null): Promise<void>;
unmute(conversationId: string, userId: string): Promise<void>;
mutedUserIds(conversationId: string, userIds: string[]): Promise<Set<string>>;
}
export const createNotificationsRepository = (db: Kysely<Database>): NotificationsRepository => ({
upsertDevice: async ({ userId, platform, token, p256dh, auth }) => {
await db
.insertInto('device_tokens')
.values({ user_id: userId, platform, token, web_p256dh: p256dh, web_auth: auth })
.onConflict((oc) =>
oc.column('token').doUpdateSet({
user_id: userId,
platform,
web_p256dh: p256dh,
web_auth: auth,
disabled_at: null,
failure_count: 0,
updated_at: new Date(),
}),
)
.execute();
},
deleteDevice: async (userId, token) => {
await db
.deleteFrom('device_tokens')
.where('user_id', '=', userId)
.where('token', '=', token)
.execute();
},
listDevicesForApi: (userId) =>
db
.selectFrom('device_tokens')
.where('user_id', '=', userId)
.where('disabled_at', 'is', null)
.select(['id', 'platform', 'created_at', 'last_success_at'])
.orderBy('created_at', 'desc')
.execute(),
activeDevicesFor: (userId) =>
db
.selectFrom('device_tokens')
.where('user_id', '=', userId)
.where('disabled_at', 'is', null)
.select(['id', 'platform', 'token', 'web_p256dh', 'web_auth'])
.execute(),
disableDevice: async (id) => {
await db
.updateTable('device_tokens')
.set({ disabled_at: new Date(), updated_at: new Date() })
.where('id', '=', id)
.execute();
},
recordSuccess: async (id) => {
await db
.updateTable('device_tokens')
.set({ last_success_at: new Date(), failure_count: 0, updated_at: new Date() })
.where('id', '=', id)
.execute();
},
recordFailure: async (id) => {
await db
.updateTable('device_tokens')
.set((eb) => ({ failure_count: eb('failure_count', '+', 1), updated_at: new Date() }))
.where('id', '=', id)
.execute();
},
getSettings: (userId) =>
db
.selectFrom('notification_settings')
.where('user_id', '=', userId)
.selectAll()
.executeTakeFirst(),
getSettingsFor: async (userIds) => {
if (userIds.length === 0) {
return new Map();
}
const rows = await db
.selectFrom('notification_settings')
.where('user_id', 'in', userIds)
.selectAll()
.execute();
return new Map(rows.map((row) => [row.user_id, row]));
},
upsertSettings: async (userId, patch) => {
await db
.insertInto('notification_settings')
.values({ user_id: userId, ...patch })
.onConflict((oc) =>
oc.column('user_id').doUpdateSet({ ...patch, updated_at: new Date() }),
)
.execute();
const row = await db
.selectFrom('notification_settings')
.where('user_id', '=', userId)
.selectAll()
.executeTakeFirstOrThrow();
return row;
},
mute: async (conversationId, userId, mutedUntil) => {
await db
.insertInto('conversation_mutes')
.values({ conversation_id: conversationId, user_id: userId, muted_until: mutedUntil })
.onConflict((oc) =>
oc.columns(['conversation_id', 'user_id']).doUpdateSet({ muted_until: mutedUntil }),
)
.execute();
},
unmute: async (conversationId, userId) => {
await db
.deleteFrom('conversation_mutes')
.where('conversation_id', '=', conversationId)
.where('user_id', '=', userId)
.execute();
},
mutedUserIds: async (conversationId, userIds) => {
if (userIds.length === 0) {
return new Set();
}
const now = new Date();
const rows = await db
.selectFrom('conversation_mutes')
.where('conversation_id', '=', conversationId)
.where('user_id', 'in', userIds)
.where((eb) =>
eb.or([eb('muted_until', 'is', null), eb('muted_until', '>', now)]),
)
.select('user_id')
.execute();
return new Set(rows.map((row) => row.user_id));
},
});

View file

@ -0,0 +1,184 @@
import type { FastifyInstance } from 'fastify';
import {
registerDeviceBodySchema,
updateSettingsBodySchema,
muteConversationBodySchema,
vapidPublicKeySchema,
notificationSettingsSchema,
deviceListSchema,
errorSchema,
} from '@altricade/core';
import type {
RegisterDeviceBody,
UpdateSettingsBody,
MuteConversationBody,
} from '@altricade/core';
const bearerAuth = [{ bearerAuth: [] }];
const unregisterBodySchema = {
type: 'object',
additionalProperties: false,
required: ['token'],
properties: { token: { type: 'string', minLength: 1, maxLength: 2048 } },
} as const;
const muteParamsSchema = {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
} as const;
export const notificationsRoutes = (app: FastifyInstance): Promise<void> => {
// Public: the VAPID public key is not a secret; the web client needs it to subscribe.
app.get(
'/notifications/vapid-public-key',
{
schema: {
tags: ['notifications'],
summary: 'Get the Web Push (VAPID) public key',
response: { 200: vapidPublicKeySchema },
},
},
async (_request, reply) =>
reply.send({ publicKey: app.notificationsService.vapidPublicKey() }),
);
app.post<{ Body: RegisterDeviceBody }>(
'/notifications/devices',
{
schema: {
tags: ['notifications'],
summary: 'Register (or re-bind) a push device',
security: bearerAuth,
body: registerDeviceBodySchema,
response: { 204: { type: 'null' }, 400: errorSchema, 401: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
await app.notificationsService.registerDevice(user.id, request.body);
return reply.code(204).send();
},
);
app.delete<{ Body: { token: string } }>(
'/notifications/devices',
{
schema: {
tags: ['notifications'],
summary: 'Unregister a push device',
security: bearerAuth,
body: unregisterBodySchema,
response: { 204: { type: 'null' }, 401: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
await app.notificationsService.unregisterDevice(user.id, request.body.token);
return reply.code(204).send();
},
);
app.get(
'/notifications/devices',
{
schema: {
tags: ['notifications'],
summary: 'List my registered push devices',
security: bearerAuth,
response: { 200: deviceListSchema, 401: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
return reply.send(await app.notificationsService.listDevices(user.id));
},
);
app.get(
'/notifications/settings',
{
schema: {
tags: ['notifications'],
summary: 'Get my notification settings',
security: bearerAuth,
response: { 200: notificationSettingsSchema, 401: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
return reply.send(await app.notificationsService.getSettings(user.id));
},
);
app.patch<{ Body: UpdateSettingsBody }>(
'/notifications/settings',
{
schema: {
tags: ['notifications'],
summary: 'Update my notification settings',
security: bearerAuth,
body: updateSettingsBodySchema,
response: { 200: notificationSettingsSchema, 400: errorSchema, 401: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
return reply.send(await app.notificationsService.updateSettings(user.id, request.body));
},
);
app.post<{ Params: { id: string }; Body: MuteConversationBody }>(
'/conversations/:id/mute',
{
schema: {
tags: ['notifications'],
summary: 'Mute a conversation',
security: bearerAuth,
params: muteParamsSchema,
body: muteConversationBodySchema,
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
await app.notificationsService.mute(user.id, request.params.id, request.body);
return reply.code(204).send();
},
);
app.delete<{ Params: { id: string } }>(
'/conversations/:id/mute',
{
schema: {
tags: ['notifications'],
summary: 'Unmute a conversation',
security: bearerAuth,
params: muteParamsSchema,
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
await app.notificationsService.unmute(user.id, request.params.id);
return reply.code(204).send();
},
);
return Promise.resolve();
};

View file

@ -0,0 +1,139 @@
import type {
NotificationSettings,
Device,
RegisterDeviceBody,
UpdateSettingsBody,
MuteConversationBody,
} from '@altricade/core';
import { HttpError } from '../../shared/http-error';
import type {
NotificationsRepository,
SettingsRow,
SettingsPatch,
DeviceApiRow,
} from './notifications.repository';
export interface NotificationsServiceDeps {
repo: NotificationsRepository;
webPushPublicKey: string;
isMember: (conversationId: string, userId: string) => Promise<boolean>;
}
export interface NotificationsService {
vapidPublicKey(): string;
registerDevice(userId: string, body: RegisterDeviceBody): Promise<void>;
unregisterDevice(userId: string, token: string): Promise<void>;
listDevices(userId: string): Promise<Device[]>;
getSettings(userId: string): Promise<NotificationSettings>;
updateSettings(userId: string, body: UpdateSettingsBody): Promise<NotificationSettings>;
mute(userId: string, conversationId: string, body: MuteConversationBody): Promise<void>;
unmute(userId: string, conversationId: string): Promise<void>;
}
const DEFAULT_SETTINGS: NotificationSettings = {
enabled: true,
quietHoursStart: null,
quietHoursEnd: null,
timezone: 'UTC',
};
const toSettings = (row: SettingsRow | undefined): NotificationSettings => {
if (row === undefined) {
return DEFAULT_SETTINGS;
}
return {
enabled: row.enabled,
quietHoursStart: row.quiet_hours_start,
quietHoursEnd: row.quiet_hours_end,
timezone: row.timezone,
};
};
const toDevice = (row: DeviceApiRow): Device => {
if (row.platform !== 'web' && row.platform !== 'android' && row.platform !== 'ios') {
throw new HttpError(500, 'internal_error', 'Unknown device platform');
}
return {
id: row.id,
platform: row.platform,
createdAt: row.created_at.toISOString(),
lastSuccessAt: row.last_success_at === null ? null : row.last_success_at.toISOString(),
};
};
export const createNotificationsService = (
deps: NotificationsServiceDeps,
): NotificationsService => {
const { repo, webPushPublicKey, isMember } = deps;
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
if (!(await isMember(conversationId, userId))) {
throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation');
}
};
return {
vapidPublicKey: () => webPushPublicKey,
registerDevice: async (userId, body) => {
if (body.platform === 'web' && (body.p256dh === undefined || body.auth === undefined)) {
throw new HttpError(400, 'invalid_subscription', 'Web push requires p256dh and auth keys');
}
await repo.upsertDevice({
userId,
platform: body.platform,
token: body.token,
p256dh: body.p256dh ?? null,
auth: body.auth ?? null,
});
},
unregisterDevice: async (userId, token) => {
await repo.deleteDevice(userId, token);
},
listDevices: async (userId) => {
const rows = await repo.listDevicesForApi(userId);
return rows.map(toDevice);
},
getSettings: async (userId) => toSettings(await repo.getSettings(userId)),
updateSettings: async (userId, body) => {
const patch: SettingsPatch = {};
if (body.enabled !== undefined) {
patch.enabled = body.enabled;
}
if (body.quietHoursStart !== undefined) {
patch.quiet_hours_start = body.quietHoursStart;
}
if (body.quietHoursEnd !== undefined) {
patch.quiet_hours_end = body.quietHoursEnd;
}
if (body.timezone !== undefined) {
patch.timezone = body.timezone;
}
return toSettings(await repo.upsertSettings(userId, patch));
},
mute: async (userId, conversationId, body) => {
await assertMember(conversationId, userId);
const until =
body.mutedUntil === undefined || body.mutedUntil === null
? null
: new Date(body.mutedUntil);
await repo.mute(conversationId, userId, until);
},
unmute: async (userId, conversationId) => {
await assertMember(conversationId, userId);
await repo.unmute(conversationId, userId);
},
};
};
declare module 'fastify' {
interface FastifyInstance {
notificationsService: NotificationsService;
}
}

View file

@ -0,0 +1,46 @@
import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
import type { PushProvider } from './provider';
const errorCode = (error: unknown): string => {
if (typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string') {
return error.code;
}
return '';
};
// Token errors that mean the registration is permanently invalid — retire it.
const DEAD_TOKEN_CODES = new Set([
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
'messaging/invalid-argument',
]);
// Firebase Cloud Messaging provider (HTTP v1 via firebase-admin). Handles both
// Android and iOS — iOS just needs an APNs key uploaded to Firebase later, with
// no code change here. `cert` reads the service-account JSON from a file path.
export const createFcmProvider = (serviceAccountFile: string): PushProvider => {
const app = initializeApp({ credential: cert(serviceAccountFile) });
const messaging = getMessaging(app);
return {
send: async (device, payload) => {
try {
await messaging.send({
token: device.token,
notification: { title: payload.title, body: payload.body },
data: {
kind: payload.kind,
conversationId: payload.conversationId,
messageId: payload.messageId ?? '',
senderId: payload.senderId,
},
android: { priority: 'high', collapseKey: payload.conversationId },
});
return { ok: true };
} catch (error) {
return { ok: false, disable: DEAD_TOKEN_CODES.has(errorCode(error)) };
}
},
};
};

View file

@ -0,0 +1,3 @@
export type { PushProvider, PushResult } from './provider';
export { createWebPushProvider } from './web-push.provider';
export { createFcmProvider } from './fcm.provider';

View file

@ -0,0 +1,11 @@
import type { PushPayload } from '@altricade/core';
import type { SendableDevice } from '../notifications.repository';
// A single delivery attempt's outcome. `disable` marks a permanently-dead token
// (unsubscribed / unregistered) so the worker can retire it; transient failures
// leave the token active for the next message.
export type PushResult = { ok: true } | { ok: false; disable: boolean };
export interface PushProvider {
send(device: SendableDevice, payload: PushPayload): Promise<PushResult>;
}

View file

@ -0,0 +1,32 @@
import webpush from 'web-push';
import type { WebPushConfig } from '../../../config';
import type { PushProvider } from './provider';
// Web Push (VAPID) provider. Serializes the PushPayload as the notification body;
// the service worker parses it to render the notification and route the tap.
export const createWebPushProvider = (config: WebPushConfig): PushProvider => {
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
return {
send: async (device, payload) => {
if (device.web_p256dh === null || device.web_auth === null) {
return { ok: false, disable: true };
}
try {
await webpush.sendNotification(
{
endpoint: device.token,
keys: { p256dh: device.web_p256dh, auth: device.web_auth },
},
JSON.stringify(payload),
{ TTL: 600 },
);
return { ok: true };
} catch (error) {
// 404/410 => the browser dropped the subscription; retire the token.
const status = error instanceof webpush.WebPushError ? error.statusCode : 0;
return { ok: false, disable: status === 404 || status === 410 };
}
},
};
};

View file

@ -26,45 +26,39 @@ declare module 'fastify' {
} }
// Thin wrapper over Centrifugo's server HTTP API. Centrifugo is a dumb pipe: the // Thin wrapper over Centrifugo's server HTTP API. Centrifugo is a dumb pipe: the
// backend is the only publisher. Wired now; actually used from Phase 2 onward. // backend is the only publisher. Exported as a plain factory so the standalone
// notifications worker can reuse it (presence checks) without a Fastify instance.
export const createCentrifugoClient = (apiUrl: string, apiKey: string): CentrifugoClient => ({
async publish(channel, data) {
const response = await fetch(`${apiUrl}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
body: JSON.stringify({ channel, data }),
});
if (!response.ok) {
throw new Error(`Centrifugo publish failed with status ${String(response.status)}`);
}
},
async presenceStats(channel) {
const response = await fetch(`${apiUrl}/presence_stats`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
body: JSON.stringify({ channel }),
});
if (!response.ok) {
return 0;
}
const data: unknown = await response.json();
return extractNumClients(data);
},
});
// Wired now; actually used from Phase 2 onward.
export const centrifugoPlugin = fp( export const centrifugoPlugin = fp(
(app) => { (app) => {
const { apiUrl, apiKey } = app.config.centrifugo; const { apiUrl, apiKey } = app.config.centrifugo;
app.decorate('centrifugo', createCentrifugoClient(apiUrl, apiKey));
const client: CentrifugoClient = {
async publish(channel, data) {
const response = await fetch(`${apiUrl}/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({ channel, data }),
});
if (!response.ok) {
throw new Error(`Centrifugo publish failed with status ${String(response.status)}`);
}
},
async presenceStats(channel) {
const response = await fetch(`${apiUrl}/presence_stats`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({ channel }),
});
if (!response.ok) {
return 0;
}
const data: unknown = await response.json();
return extractNumClients(data);
},
};
app.decorate('centrifugo', client);
return Promise.resolve(); return Promise.resolve();
}, },
{ name: 'centrifugo' }, { name: 'centrifugo' },

View file

@ -0,0 +1,95 @@
import pg from 'pg';
import { Kysely, PostgresDialect } from 'kysely';
import { Worker } from 'bullmq';
import { userChannel } from '@altricade/core';
import { loadConfig } from './config';
import type { Database } from './db/schema';
import { createMessagesRepository } from './modules/messages';
import { createConversationsRepository } from './modules/conversations';
import {
createNotificationsRepository,
createDispatcher,
createBullConnection,
createWebPushProvider,
createFcmProvider,
MESSAGE_JOB,
} from './modules/notifications';
import type { PushProvider, MessageJobData } from './modules/notifications';
import { createCentrifugoClient } from './plugins/centrifugo';
// Match the backend's int8 parsing so seq/counters come back as JS numbers.
pg.types.setTypeParser(20, (value) => Number(value));
const log = (message: string): void => {
// Worker logs go straight to stdout for docker log capture.
process.stdout.write(`[notifications] ${message}\n`);
};
const start = (): void => {
const config = loadConfig();
const pool = new pg.Pool({ connectionString: config.databaseUrl });
const db = new Kysely<Database>({ dialect: new PostgresDialect({ pool }) });
const centrifugo = createCentrifugoClient(config.centrifugo.apiUrl, config.centrifugo.apiKey);
const providers = new Map<string, PushProvider>();
if (config.notifications.webPush !== null) {
providers.set('web', createWebPushProvider(config.notifications.webPush));
log('web push provider ready');
} else {
log('web push disabled (no VAPID keys configured)');
}
if (config.notifications.fcmServiceAccountFile !== null) {
const fcm = createFcmProvider(config.notifications.fcmServiceAccountFile);
providers.set('android', fcm);
// iOS routes through FCM too; enabled the moment an APNs key is added to Firebase.
if (config.notifications.apnsEnabled) {
providers.set('ios', fcm);
}
log(`fcm provider ready (android${config.notifications.apnsEnabled ? ' + ios' : ''})`);
} else {
log('fcm disabled (no service account configured)');
}
const dispatcher = createDispatcher({
repo: createNotificationsRepository(db),
messages: createMessagesRepository(db),
conversations: createConversationsRepository(db),
isOnline: async (userId) => (await centrifugo.presenceStats(userChannel(userId))) > 0,
providers,
log,
});
const connection = createBullConnection(config.redisUrl);
const worker = new Worker<MessageJobData>(
config.notifications.queueName,
async (job) => {
if (job.name === MESSAGE_JOB) {
await dispatcher.handleMessageJob(job.data);
}
},
{ connection, concurrency: 8 },
);
worker.on('ready', () => {
log(`listening on queue "${config.notifications.queueName}"`);
});
worker.on('failed', (job, error) => {
log(`job ${job?.id ?? '?'} failed: ${error.message}`);
});
const shutdown = async (signal: string): Promise<void> => {
log(`received ${signal}, shutting down`);
await worker.close();
await connection.quit();
await db.destroy();
process.exit(0);
};
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
};
start();

View file

@ -4,7 +4,7 @@ import { defineConfig } from 'tsup';
// self-contained ESM output for the slim runtime image. Third-party deps stay // self-contained ESM output for the slim runtime image. Third-party deps stay
// external and are installed as production deps in the Docker runtime stage. // external and are installed as production deps in the Docker runtime stage.
export default defineConfig({ export default defineConfig({
entry: ['src/server.ts'], entry: ['src/server.ts', 'src/worker.ts'],
format: ['esm'], format: ['esm'],
target: 'node22', target: 'node22',
platform: 'node', platform: 'node',

View file

@ -37,3 +37,13 @@ export { markRead } from './conversations';
export { getPresence, heartbeat } from './presence'; export { getPresence, heartbeat } from './presence';
export { getUploadUrl, getAvatarUploadUrl, getMediaUrl, uploadToUrl } from './media'; export { getUploadUrl, getAvatarUploadUrl, getMediaUrl, uploadToUrl } from './media';
export type { UploadTarget, AvatarTarget } from './media'; export type { UploadTarget, AvatarTarget } from './media';
export {
getVapidPublicKey,
registerDevice,
unregisterDevice,
listDevices,
getNotificationSettings,
updateNotificationSettings,
muteConversation,
unmuteConversation,
} from './notifications';

View file

@ -0,0 +1,62 @@
import {
vapidPublicKeySchema,
notificationSettingsSchema,
deviceListSchema,
} from '../schemas/index';
import type {
RegisterDeviceBody,
UpdateSettingsBody,
MuteConversationBody,
} from '../schemas/index';
import type { NotificationSettings, Device } from '../types/notification';
import { compileValidator, parse, requestJson } from './http';
import type { ApiClientConfig } from './http';
const vapidV = compileValidator<{ publicKey: string }>(vapidPublicKeySchema);
const settingsV = compileValidator<NotificationSettings>(notificationSettingsSchema);
const deviceListV = compileValidator<Device[]>(deviceListSchema);
export const getVapidPublicKey = async (config: ApiClientConfig): Promise<string> => {
const result = parse(vapidV, await requestJson(config, 'GET', '/notifications/vapid-public-key'));
return result.publicKey;
};
export const registerDevice = async (
config: ApiClientConfig,
body: RegisterDeviceBody,
): Promise<void> => {
await requestJson(config, 'POST', '/notifications/devices', body);
};
export const unregisterDevice = async (config: ApiClientConfig, token: string): Promise<void> => {
await requestJson(config, 'DELETE', '/notifications/devices', { token });
};
export const listDevices = async (config: ApiClientConfig): Promise<Device[]> =>
parse(deviceListV, await requestJson(config, 'GET', '/notifications/devices'));
export const getNotificationSettings = async (
config: ApiClientConfig,
): Promise<NotificationSettings> =>
parse(settingsV, await requestJson(config, 'GET', '/notifications/settings'));
export const updateNotificationSettings = async (
config: ApiClientConfig,
body: UpdateSettingsBody,
): Promise<NotificationSettings> =>
parse(settingsV, await requestJson(config, 'PATCH', '/notifications/settings', body));
export const muteConversation = async (
config: ApiClientConfig,
conversationId: string,
body: MuteConversationBody,
): Promise<void> => {
await requestJson(config, 'POST', `/conversations/${conversationId}/mute`, body);
};
export const unmuteConversation = async (
config: ApiClientConfig,
conversationId: string,
): Promise<void> => {
await requestJson(config, 'DELETE', `/conversations/${conversationId}/mute`);
};

View file

@ -26,6 +26,21 @@ export {
mediaUrlSchema, mediaUrlSchema,
} from './media'; } from './media';
export type { MediaRef, MediaKind, UploadUrlBody, AvatarUploadBody, SetAvatarBody } from './media'; export type { MediaRef, MediaKind, UploadUrlBody, AvatarUploadBody, SetAvatarBody } from './media';
export {
registerDeviceBodySchema,
updateSettingsBodySchema,
muteConversationBodySchema,
vapidPublicKeySchema,
notificationSettingsSchema,
deviceSchema,
deviceListSchema,
} from './notifications';
export type {
DevicePlatform,
RegisterDeviceBody,
UpdateSettingsBody,
MuteConversationBody,
} from './notifications';
export { export {
publicUserSchema, publicUserSchema,
publicUserListSchema, publicUserListSchema,

View file

@ -0,0 +1,77 @@
import type { FromSchema } from 'json-schema-to-ts';
const PLATFORM = { type: 'string', enum: ['web', 'android', 'ios'] } as const;
// Register (or re-bind) a push device. `token` is the FCM registration token for
// native, or the Web Push endpoint URL for web; web additionally carries the
// subscription keys (p256dh + auth). The backend validates that web has keys.
export const registerDeviceBodySchema = {
type: 'object',
additionalProperties: false,
required: ['platform', 'token'],
properties: {
platform: PLATFORM,
token: { type: 'string', minLength: 1, maxLength: 2048 },
p256dh: { type: 'string', minLength: 1, maxLength: 512 },
auth: { type: 'string', minLength: 1, maxLength: 512 },
},
} as const;
// Partial update of notification preferences. `null` quiet-hours clears them.
export const updateSettingsBodySchema = {
type: 'object',
additionalProperties: false,
properties: {
enabled: { type: 'boolean' },
quietHoursStart: { type: ['integer', 'null'], minimum: 0, maximum: 1439 },
quietHoursEnd: { type: ['integer', 'null'], minimum: 0, maximum: 1439 },
timezone: { type: 'string', minLength: 1, maxLength: 64 },
},
} as const;
// Mute a conversation. `mutedUntil` null (or omitted) means mute indefinitely.
export const muteConversationBodySchema = {
type: 'object',
additionalProperties: false,
properties: {
mutedUntil: { type: ['string', 'null'], format: 'date-time' },
},
} as const;
export const vapidPublicKeySchema = {
type: 'object',
additionalProperties: false,
required: ['publicKey'],
properties: { publicKey: { type: 'string' } },
} as const;
export const notificationSettingsSchema = {
type: 'object',
additionalProperties: false,
required: ['enabled', 'quietHoursStart', 'quietHoursEnd', 'timezone'],
properties: {
enabled: { type: 'boolean' },
quietHoursStart: { type: ['integer', 'null'] },
quietHoursEnd: { type: ['integer', 'null'] },
timezone: { type: 'string' },
},
} as const;
export const deviceSchema = {
type: 'object',
additionalProperties: false,
required: ['id', 'platform', 'createdAt', 'lastSuccessAt'],
properties: {
id: { type: 'string' },
platform: PLATFORM,
createdAt: { type: 'string' },
lastSuccessAt: { type: ['string', 'null'] },
},
} as const;
export const deviceListSchema = { type: 'array', items: deviceSchema } as const;
export type DevicePlatform = FromSchema<typeof PLATFORM>;
export type RegisterDeviceBody = FromSchema<typeof registerDeviceBodySchema>;
export type UpdateSettingsBody = FromSchema<typeof updateSettingsBodySchema>;
export type MuteConversationBody = FromSchema<typeof muteConversationBodySchema>;

View file

@ -7,3 +7,10 @@ export type { Conversation, ConversationType, ConversationMember } from './conve
export type { Contact } from './contact'; export type { Contact } from './contact';
export type { Message, ReactionSummary, MediaRef } from './message'; export type { Message, ReactionSummary, MediaRef } from './message';
export type { Presence } from './presence'; export type { Presence } from './presence';
export type {
NotificationSettings,
Device,
DevicePlatform,
PushPayload,
PushKind,
} from './notification';

View file

@ -0,0 +1,33 @@
import type { DevicePlatform } from '../schemas/notifications';
export type { DevicePlatform } from '../schemas/notifications';
export interface NotificationSettings {
enabled: boolean;
/** Minutes since local midnight, or null when quiet hours are off. */
quietHoursStart: number | null;
quietHoursEnd: number | null;
/** IANA timezone the quiet-hours window is evaluated in. */
timezone: string;
}
export interface Device {
id: string;
platform: DevicePlatform;
createdAt: string;
lastSuccessAt: string | null;
}
// The `data` payload delivered to a device (web SW / mobile handler). `kind`
// tells the client how to route the tap; `conversationId` drives the Telegram-
// style deep-link into the originating chat.
export type PushKind = 'message' | 'conversation';
export interface PushPayload {
kind: PushKind;
conversationId: string;
messageId: string | null;
senderId: string;
title: string;
body: string;
}

View file

@ -32,7 +32,9 @@ const fsdLayerOverrides = FSD_LAYERS.flatMap((layer, index) => {
}); });
export default [ export default [
{ ignores: ['dist/**'] }, // `public/` holds static assets (incl. the service worker) served as-is; it is
// not part of the typed TS project, so keep it out of the typed-lint graph.
{ ignores: ['dist/**', 'public/**'] },
...base, ...base,
{ {
files: ['src/**/*.{ts,tsx}'], files: ['src/**/*.{ts,tsx}'],

48
packages/web/public/sw.js Normal file
View file

@ -0,0 +1,48 @@
/* Altricade service worker Web Push display + Telegram-style tap-to-open.
Plain JS (runs in the SW global scope, outside the TS/React build). */
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = {};
}
const title = typeof payload.title === 'string' ? payload.title : 'New message';
const body = typeof payload.body === 'string' ? payload.body : '';
const conversationId = typeof payload.conversationId === 'string' ? payload.conversationId : '';
event.waitUntil(
self.registration.showNotification(title, {
body,
// Collapse multiple messages from the same chat into one notification.
tag: conversationId || undefined,
renotify: Boolean(conversationId),
data: { conversationId },
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const data = event.notification.data || {};
const conversationId = typeof data.conversationId === 'string' ? data.conversationId : '';
const url = conversationId ? `/?conversation=${conversationId}` : '/';
event.waitUntil(
(async () => {
const clientList = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
for (const client of clientList) {
if ('focus' in client) {
await client.focus();
client.postMessage({ type: 'notification.open', conversationId });
return;
}
}
if (self.clients.openWindow) {
await self.clients.openWindow(url);
}
})(),
);
});

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react'; import type { ReactElement, SyntheticEvent } from 'react';
import type { Conversation, PublicUser, User } from '@altricade/core'; import type { Conversation, Message, MediaRef, PublicUser, User } from '@altricade/core';
import { heartbeat, getAvatarUploadUrl, uploadToUrl, setAvatar } from '@altricade/core/api'; import { heartbeat, getAvatarUploadUrl, uploadToUrl, setAvatar } from '@altricade/core/api';
import { SessionProvider, useSession } from '../entities/session'; import { SessionProvider, useSession } from '../entities/session';
import { AuthForm } from '../features/auth'; import { AuthForm } from '../features/auth';
@ -8,12 +8,29 @@ import { RealtimeProvider, useRealtime } from '../features/realtime';
import { useConversations, ConversationSidebar } from '../features/conversations'; import { useConversations, ConversationSidebar } from '../features/conversations';
import { ContactsPanel } from '../features/contacts'; import { ContactsPanel } from '../features/contacts';
import { ChatView } from '../features/messaging'; import { ChatView } from '../features/messaging';
import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications';
import { apiConfig } from '../shared/api'; import { apiConfig } from '../shared/api';
import { useTheme } from '../shared/theme'; import { useTheme } from '../shared/theme';
import type { ThemePreference } from '../shared/theme'; import type { ThemePreference } from '../shared/theme';
const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system']; const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'];
const mediaLabel = (media: MediaRef | null): string => {
if (media === null) {
return 'New message';
}
switch (media.kind) {
case 'image':
return 'Photo';
case 'video':
return 'Video';
case 'voice':
return 'Voice message';
default:
return 'File';
}
};
const ThemeSwitch = (): ReactElement => { const ThemeSwitch = (): ReactElement => {
const { preference, setPreference } = useTheme(); const { preference, setPreference } = useTheme();
return ( return (
@ -43,6 +60,55 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
}; };
const { updateUser } = useSession(); const { updateUser } = useSession();
const { state } = useRealtime(); const { state } = useRealtime();
const { notify, setOpener } = useNotifications();
const [current, setCurrent] = useState<Conversation | null>(null);
const convRef = useRef<Conversation[]>([]);
// Toast (or OS notification when hidden) for messages arriving in other chats.
const onIncoming = useCallback(
(message: Message): void => {
const conversation = convRef.current.find((item) => item.id === message.conversationId);
const isGroup = conversation?.type === 'group';
const text = message.content.length > 0 ? message.content : mediaLabel(message.media);
notify({
conversationId: message.conversationId,
title: isGroup ? (conversation.title ?? 'Group') : message.sender.displayName,
body: isGroup ? `${message.sender.displayName}: ${text}` : text,
});
},
[notify],
);
const { conversations, onlineMap, startDirect, createGroupChat } = useConversations(
user.id,
current?.id ?? null,
onIncoming,
);
convRef.current = conversations;
// Let notification taps (in-app toast or SW message) open the right chat.
useEffect(() => {
setOpener((conversationId) => {
const conversation = convRef.current.find((item) => item.id === conversationId);
if (conversation !== undefined) {
setCurrent(conversation);
}
});
}, [setOpener]);
// Deep-link: /?conversation=<id> (from a push opened in a fresh tab).
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const target = params.get('conversation');
if (target === null || conversations.length === 0) {
return;
}
const conversation = conversations.find((item) => item.id === target);
if (conversation !== undefined) {
setCurrent(conversation);
window.history.replaceState({}, '', window.location.pathname);
}
}, [conversations]);
const onAvatar = (event: SyntheticEvent<HTMLInputElement>): void => { const onAvatar = (event: SyntheticEvent<HTMLInputElement>): void => {
const input = event.currentTarget; const input = event.currentTarget;
@ -58,11 +124,6 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
updateUser(await setAvatar(apiConfig, { objectKey: target.objectKey })); updateUser(await setAvatar(apiConfig, { objectKey: target.objectKey }));
})(); })();
}; };
const [current, setCurrent] = useState<Conversation | null>(null);
const { conversations, onlineMap, startDirect, createGroupChat } = useConversations(
user.id,
current?.id ?? null,
);
// Keep-alive heartbeat so our own last-seen stays fresh while connected. // Keep-alive heartbeat so our own last-seen stays fresh while connected.
useEffect(() => { useEffect(() => {
@ -144,12 +205,18 @@ const Shell = (): ReactElement => {
} }
return ( return (
<RealtimeProvider> <RealtimeProvider>
<Dashboard <NotificationsProvider>
user={user} <Dashboard
onLogout={() => { user={user}
void logout(); onLogout={() => {
}} // Detach this browser's push subscription before the token clears.
/> void (async () => {
await unregisterWebPush();
await logout();
})();
}}
/>
</NotificationsProvider>
</RealtimeProvider> </RealtimeProvider>
); );
}; };

View file

@ -479,12 +479,66 @@ body {
cursor: pointer; cursor: pointer;
} }
.attach-btn { .icon-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0 0.5rem; justify-content: center;
width: 38px;
height: 38px;
flex: 0 0 auto;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: none;
color: var(--color-textMuted);
cursor: pointer; cursor: pointer;
font-size: 1.2rem; }
.icon-btn:hover {
background: var(--color-surface);
color: var(--color-text);
}
.icon-btn.send {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.recording-bar {
align-items: center;
}
.record-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #e5484d;
animation: record-pulse 1s ease-in-out infinite;
}
.record-label {
flex: 1;
color: var(--color-text);
font-size: 0.9rem;
}
.record-preview {
width: 72px;
height: 72px;
border-radius: 8px;
object-fit: cover;
background: #000;
}
@keyframes record-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
} }
.media-img { .media-img {
@ -501,5 +555,58 @@ body {
} }
.media-file { .media-file {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-accent); color: var(--color-accent);
} }
/* In-app notification toasts */
.toast-stack {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
max-width: min(360px, calc(100vw - 2rem));
}
.toast {
display: flex;
flex-direction: column;
gap: 0.15rem;
text-align: left;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
cursor: pointer;
animation: toast-in 0.18s ease;
}
.toast-title {
font-size: 0.9rem;
}
.toast-body {
font-size: 0.85rem;
color: var(--color-textMuted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View file

@ -32,7 +32,11 @@ const upsert = (list: Conversation[], conversation: Conversation): Conversation[
const byRecency = (a: Conversation, b: Conversation): number => const byRecency = (a: Conversation, b: Conversation): number =>
b.lastMessageAt.localeCompare(a.lastMessageAt); b.lastMessageAt.localeCompare(a.lastMessageAt);
export const useConversations = (userId: string, currentId: string | null): UseConversations => { export const useConversations = (
userId: string,
currentId: string | null,
onIncoming?: (message: Message) => void,
): UseConversations => {
const { subscribe, onPresence, presence } = useRealtime(); const { subscribe, onPresence, presence } = useRealtime();
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -40,6 +44,8 @@ export const useConversations = (userId: string, currentId: string | null): UseC
const presentByChannel = useRef<Map<string, Set<string>>>(new Map()); const presentByChannel = useRef<Map<string, Set<string>>>(new Map());
const currentIdRef = useRef(currentId); const currentIdRef = useRef(currentId);
currentIdRef.current = currentId; currentIdRef.current = currentId;
const onIncomingRef = useRef(onIncoming);
onIncomingRef.current = onIncoming;
const recomputeOnline = useCallback((): void => { const recomputeOnline = useCallback((): void => {
const online: Record<string, boolean> = {}; const online: Record<string, boolean> = {};
@ -56,13 +62,17 @@ export const useConversations = (userId: string, currentId: string | null): UseC
if (message.senderId === userId) { if (message.senderId === userId) {
return; return;
} }
const isCurrent = currentIdRef.current === message.conversationId;
// Online = app open, so surface an in-app toast for messages in other chats.
if (!isCurrent) {
onIncomingRef.current?.(message);
}
setConversations((prev) => setConversations((prev) =>
prev prev
.map((c) => { .map((c) => {
if (c.id !== message.conversationId) { if (c.id !== message.conversationId) {
return c; return c;
} }
const isCurrent = currentIdRef.current === c.id;
return { return {
...c, ...c,
lastMessageAt: message.createdAt, lastMessageAt: message.createdAt,

View file

@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export type RecordKind = 'voice' | 'video';
export interface Recorder {
/** The kind currently recording, or null when idle. */
recording: RecordKind | null;
elapsedMs: number;
/** Live camera stream while recording video (for an in-composer preview). */
previewStream: MediaStream | null;
start: (kind: RecordKind) => void;
/** Stop and send the recording. */
finish: () => void;
/** Stop and discard. */
cancel: () => void;
}
// In-app voice/video message capture via MediaRecorder. Produces a webm File and
// hands it to `onComplete`, which uploads + sends it as a media message.
export const useRecorder = (onComplete: (file: File) => void): Recorder => {
const [recording, setRecording] = useState<RecordKind | null>(null);
const [elapsedMs, setElapsedMs] = useState(0);
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const kindRef = useRef<RecordKind | null>(null);
const cancelledRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const teardownStream = useCallback((): void => {
const stream = streamRef.current;
if (stream !== null) {
for (const track of stream.getTracks()) {
track.stop();
}
streamRef.current = null;
}
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const start = useCallback(
(kind: RecordKind): void => {
if (recording !== null) {
return;
}
const constraints: MediaStreamConstraints =
kind === 'voice' ? { audio: true } : { audio: true, video: true };
void (async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream;
kindRef.current = kind;
cancelledRef.current = false;
chunksRef.current = [];
if (kind === 'video') {
setPreviewStream(stream);
}
const recorder = new MediaRecorder(stream);
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
teardownStream();
const chunks = chunksRef.current;
if (!cancelledRef.current && chunks.length > 0) {
const isVideo = kindRef.current === 'video';
const type = isVideo ? 'video/webm' : 'audio/webm';
const name = isVideo ? 'video-message.webm' : 'voice-message.webm';
onCompleteRef.current(new File(chunks, name, { type }));
}
recorderRef.current = null;
setRecording(null);
setPreviewStream(null);
setElapsedMs(0);
};
recorderRef.current = recorder;
recorder.start();
setRecording(kind);
const startedAt = Date.now();
timerRef.current = setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 200);
} catch {
// Permission denied or no device — reset silently.
teardownStream();
setRecording(null);
setPreviewStream(null);
}
})();
},
[recording, teardownStream],
);
const stop = useCallback(
(cancelled: boolean): void => {
cancelledRef.current = cancelled;
const recorder = recorderRef.current;
if (recorder !== null && recorder.state !== 'inactive') {
recorder.stop();
} else {
teardownStream();
}
},
[teardownStream],
);
const finish = useCallback((): void => {
stop(false);
}, [stop]);
const cancel = useCallback((): void => {
stop(true);
}, [stop]);
useEffect(() => teardownStream, [teardownStream]);
return { recording, elapsedMs, previewStream, start, finish, cancel };
};

View file

@ -1,9 +1,26 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react'; import type { ReactElement, SyntheticEvent } from 'react';
import type { Conversation, Message, PublicUser } from '@altricade/core'; import type { Conversation, Message, PublicUser } from '@altricade/core';
import { getMediaUrl } from '@altricade/core/api'; import { getMediaUrl } from '@altricade/core/api';
import { apiConfig } from '../../../shared/api'; import { apiConfig } from '../../../shared/api';
import {
PaperclipIcon,
MicIcon,
VideoIcon,
StopIcon,
SendIcon,
CloseIcon,
FileIcon,
} from '../../../shared/ui';
import { useConversationMessages } from '../model'; import { useConversationMessages } from '../model';
import { useRecorder } from '../recorder';
const formatElapsed = (ms: number): string => {
const total = Math.floor(ms / 1000);
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${String(minutes)}:${seconds < 10 ? '0' : ''}${String(seconds)}`;
};
interface Props { interface Props {
conversation: Conversation; conversation: Conversation;
@ -65,7 +82,8 @@ const MediaView = ({
} }
return ( return (
<a href={url} download={media.name} className="media-file"> <a href={url} download={media.name} className="media-file">
📎 {media.name} <FileIcon size={16} />
<span>{media.name}</span>
</a> </a>
); );
}; };
@ -84,6 +102,18 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
notifyTyping, notifyTyping,
} = useConversationMessages(conversation, me); } = useConversationMessages(conversation, me);
const [text, setText] = useState(''); const [text, setText] = useState('');
const recorder = useRecorder((file) => {
void sendMedia(file, '');
});
const previewRef = useRef<HTMLVideoElement>(null);
// Mirror the live camera stream into the in-composer preview while recording.
useEffect(() => {
const element = previewRef.current;
if (element !== null) {
element.srcObject = recorder.previewStream;
}
}, [recorder.previewStream]);
const submit = async (event: SyntheticEvent): Promise<void> => { const submit = async (event: SyntheticEvent): Promise<void> => {
event.preventDefault(); event.preventDefault();
@ -199,26 +229,72 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
))} ))}
</ul> </ul>
{typingUserIds.length > 0 ? <p className="typing muted">typing</p> : null} {typingUserIds.length > 0 ? <p className="typing muted">typing</p> : null}
<form {recorder.recording !== null ? (
className="composer" <div className="composer recording-bar">
onSubmit={(event) => { {recorder.recording === 'video' ? (
void submit(event); <video ref={previewRef} className="record-preview" autoPlay muted playsInline />
}} ) : null}
> <span className="record-dot" aria-hidden="true" />
<label className="attach-btn"> <span className="record-label">
📎 {recorder.recording === 'video' ? 'Recording video' : 'Recording voice'} ·{' '}
<input type="file" hidden onChange={onAttach} /> {formatElapsed(recorder.elapsedMs)}
</label> </span>
<input <button type="button" className="icon-btn" title="Cancel" onClick={recorder.cancel}>
value={text} <CloseIcon />
placeholder="Write a message…" </button>
onChange={(event) => { <button
setText(event.target.value); type="button"
notifyTyping(); className="icon-btn send"
title="Stop and send"
onClick={recorder.finish}
>
<StopIcon />
</button>
</div>
) : (
<form
className="composer"
onSubmit={(event) => {
void submit(event);
}} }}
/> >
<button type="submit">Send</button> <label className="icon-btn" title="Attach file">
</form> <PaperclipIcon />
<input type="file" hidden onChange={onAttach} />
</label>
<button
type="button"
className="icon-btn"
title="Record voice message"
onClick={() => {
recorder.start('voice');
}}
>
<MicIcon />
</button>
<button
type="button"
className="icon-btn"
title="Record video message"
onClick={() => {
recorder.start('video');
}}
>
<VideoIcon />
</button>
<input
value={text}
placeholder="Write a message…"
onChange={(event) => {
setText(event.target.value);
notifyTyping();
}}
/>
<button type="submit" className="icon-btn send" title="Send">
<SendIcon />
</button>
</form>
)}
</section> </section>
); );
}; };

View file

@ -0,0 +1,2 @@
export { NotificationsProvider, useNotifications, unregisterWebPush } from './model';
export type { NotificationsContextValue, ToastInput } from './model';

View file

@ -0,0 +1,225 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactElement, ReactNode } from 'react';
import { getVapidPublicKey, registerDevice, unregisterDevice } from '@altricade/core/api';
import { apiConfig } from '../../shared/api';
export interface ToastInput {
conversationId: string;
title: string;
body: string;
}
interface Toast extends ToastInput {
id: string;
}
export interface NotificationsContextValue {
/** Surface an incoming message: in-app toast when visible, OS notification when hidden. */
notify: (toast: ToastInput) => void;
/** Register the handler that opens a conversation by id (from a tap / SW message). */
setOpener: (open: (conversationId: string) => void) => void;
}
const NotificationsContext = createContext<NotificationsContextValue | null>(null);
const TOAST_TTL_MS = 5000;
const urlBase64ToKey = (base64: string): ArrayBuffer => {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(normalized);
const buffer = new ArrayBuffer(raw.length);
const view = new Uint8Array(buffer);
for (let index = 0; index < raw.length; index += 1) {
view[index] = raw.charCodeAt(index);
}
return buffer;
};
// Register the service worker + Web Push subscription and hand the subscription
// to the backend. No-ops gracefully when the browser lacks push, the server has
// no VAPID key, or the user denies permission.
const registerWebPush = async (): Promise<void> => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
const publicKey = await getVapidPublicKey(apiConfig);
if (publicKey === '') {
return;
}
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
const permission =
Notification.permission === 'default'
? await Notification.requestPermission()
: Notification.permission;
if (permission !== 'granted') {
return;
}
const existing = await registration.pushManager.getSubscription();
const subscription =
existing ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToKey(publicKey),
}));
const json = subscription.toJSON();
const endpoint = json.endpoint;
const p256dh = json.keys?.['p256dh'];
const auth = json.keys?.['auth'];
if (endpoint === undefined || p256dh === undefined || auth === undefined) {
return;
}
await registerDevice(apiConfig, { platform: 'web', token: endpoint, p256dh, auth });
};
// Detach this browser's push subscription from the account (call before logout).
export const unregisterWebPush = async (): Promise<void> => {
if (!('serviceWorker' in navigator)) {
return;
}
const registration = await navigator.serviceWorker.getRegistration();
if (registration === undefined) {
return;
}
const subscription = await registration.pushManager.getSubscription();
if (subscription === null) {
return;
}
try {
await unregisterDevice(apiConfig, subscription.endpoint);
} catch {
/* best-effort cleanup */
}
};
export const NotificationsProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [toasts, setToasts] = useState<Toast[]>([]);
const openerRef = useRef<(conversationId: string) => void>(() => undefined);
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const dismiss = useCallback((id: string): void => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
const timer = timers.current.get(id);
if (timer !== undefined) {
clearTimeout(timer);
timers.current.delete(id);
}
}, []);
const open = useCallback((conversationId: string): void => {
openerRef.current(conversationId);
}, []);
const notify = useCallback(
(toast: ToastInput): void => {
// Visible tab → in-app toast. Hidden tab (but still connected) → OS notification.
if (document.visibilityState === 'visible') {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { ...toast, id }]);
timers.current.set(
id,
setTimeout(() => {
dismiss(id);
}, TOAST_TTL_MS),
);
return;
}
if ('Notification' in window && Notification.permission === 'granted') {
const notification = new Notification(toast.title, {
body: toast.body,
tag: toast.conversationId,
});
notification.onclick = () => {
window.focus();
open(toast.conversationId);
notification.close();
};
}
},
[dismiss, open],
);
const setOpener = useCallback((fn: (conversationId: string) => void): void => {
openerRef.current = fn;
}, []);
// Register push once on mount.
useEffect(() => {
void registerWebPush();
}, []);
// Taps on a real push (handled by the SW) arrive here to drive navigation.
useEffect(() => {
if (!('serviceWorker' in navigator)) {
return undefined;
}
const handler = (event: MessageEvent<unknown>): void => {
const data = event.data;
if (
typeof data === 'object' &&
data !== null &&
'type' in data &&
data.type === 'notification.open' &&
'conversationId' in data &&
typeof data.conversationId === 'string'
) {
open(data.conversationId);
}
};
navigator.serviceWorker.addEventListener('message', handler);
return () => {
navigator.serviceWorker.removeEventListener('message', handler);
};
}, [open]);
useEffect(() => {
const map = timers.current;
return () => {
for (const timer of map.values()) {
clearTimeout(timer);
}
map.clear();
};
}, []);
const value = useMemo<NotificationsContextValue>(() => ({ notify, setOpener }), [notify, setOpener]);
return (
<NotificationsContext.Provider value={value}>
{children}
<div className="toast-stack">
{toasts.map((toast) => (
<button
key={toast.id}
type="button"
className="toast"
onClick={() => {
open(toast.conversationId);
dismiss(toast.id);
}}
>
<strong className="toast-title">{toast.title}</strong>
<span className="toast-body">{toast.body}</span>
</button>
))}
</div>
</NotificationsContext.Provider>
);
};
export const useNotifications = (): NotificationsContextValue => {
const context = useContext(NotificationsContext);
if (context === null) {
throw new Error('useNotifications must be used within a NotificationsProvider');
}
return context;
};

View file

@ -0,0 +1,87 @@
import type { ReactElement } from 'react';
// Inline stroke icons so glyphs render identically on every OS/browser (no
// emoji font variance). `currentColor` lets callers theme them via CSS.
interface IconProps {
size?: number;
className?: string;
}
const base = (size: number, className: string | undefined, children: ReactElement): ReactElement => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
{children}
</svg>
);
export const PaperclipIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />,
);
export const MicIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<rect x="9" y="2" width="6" height="12" rx="3" />
<path d="M5 10a7 7 0 0 0 14 0" />
<line x1="12" y1="17" x2="12" y2="22" />
<line x1="8" y1="22" x2="16" y2="22" />
</>,
);
export const VideoIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<rect x="2" y="6" width="14" height="12" rx="2" />
<path d="M22 8l-6 4 6 4V8z" />
</>,
);
export const StopIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(size, className, <rect x="6" y="6" width="12" height="12" rx="2" />);
export const SendIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<line x1="22" y1="2" x2="11" y2="13" />
<path d="M22 2l-7 20-4-9-9-4 20-7z" />
</>,
);
export const CloseIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>,
);
export const FileIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</>,
);

View file

@ -0,0 +1,9 @@
export {
PaperclipIcon,
MicIcon,
VideoIcon,
StopIcon,
SendIcon,
CloseIcon,
FileIcon,
} from './icons';

File diff suppressed because it is too large Load diff