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:
parent
db5c1610b3
commit
47fbf861ee
44 changed files with 3690 additions and 82 deletions
15
.env.example
15
.env.example
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
4
infra/secrets/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Never commit real credentials.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!README.md
|
||||||
16
infra/secrets/README.md
Normal file
16
infra/secrets/README.md
Normal 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.
|
||||||
|
|
@ -27,7 +27,9 @@
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": ">=0.28.1"
|
"esbuild": ">=0.28.1",
|
||||||
|
"ioredis": "^5.11.1",
|
||||||
|
"uuid": "^11.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
packages/backend/migrations/1720000000006_notifications.cjs
Normal file
76
packages/backend/migrations/1720000000006_notifications.cjs
Normal 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');
|
||||||
|
};
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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')}`);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
15
packages/backend/src/modules/notifications/index.ts
Normal file
15
packages/backend/src/modules/notifications/index.ts
Normal 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';
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type { PushProvider, PushResult } from './provider';
|
||||||
|
export { createWebPushProvider } from './web-push.provider';
|
||||||
|
export { createFcmProvider } from './fcm.provider';
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
95
packages/backend/src/worker.ts
Normal file
95
packages/backend/src/worker.ts
Normal 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();
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
62
packages/core/src/api/notifications.ts
Normal file
62
packages/core/src/api/notifications.ts
Normal 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`);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
77
packages/core/src/schemas/notifications.ts
Normal file
77
packages/core/src/schemas/notifications.ts
Normal 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>;
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
33
packages/core/src/types/notification.ts
Normal file
33
packages/core/src/types/notification.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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
48
packages/web/public/sw.js
Normal 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);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
126
packages/web/src/features/messaging/recorder.ts
Normal file
126
packages/web/src/features/messaging/recorder.ts
Normal 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 };
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
2
packages/web/src/features/notifications/index.ts
Normal file
2
packages/web/src/features/notifications/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { NotificationsProvider, useNotifications, unregisterWebPush } from './model';
|
||||||
|
export type { NotificationsContextValue, ToastInput } from './model';
|
||||||
225
packages/web/src/features/notifications/model.tsx
Normal file
225
packages/web/src/features/notifications/model.tsx
Normal 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;
|
||||||
|
};
|
||||||
87
packages/web/src/shared/ui/icons.tsx
Normal file
87
packages/web/src/shared/ui/icons.tsx
Normal 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" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
9
packages/web/src/shared/ui/index.ts
Normal file
9
packages/web/src/shared/ui/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export {
|
||||||
|
PaperclipIcon,
|
||||||
|
MicIcon,
|
||||||
|
VideoIcon,
|
||||||
|
StopIcon,
|
||||||
|
SendIcon,
|
||||||
|
CloseIcon,
|
||||||
|
FileIcon,
|
||||||
|
} from './icons';
|
||||||
1457
pnpm-lock.yaml
1457
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue