feat: Add notifications

This commit is contained in:
2026-03-30 04:32:24 +02:00
parent b7d4bf20e3
commit 42ac712571
32 changed files with 1974 additions and 14 deletions

View File

@@ -0,0 +1,158 @@
import type { Message, Room } from '../../../shared-kernel';
import type {
NotificationDeliveryContext,
NotificationDisplayPayload,
NotificationsSettings,
RoomUnreadCounts
} from './notification.models';
export const DEFAULT_TEXT_CHANNEL_ID = 'general';
const MESSAGE_PREVIEW_LIMIT = 140;
export function resolveMessageChannelId(message: Pick<Message, 'channelId'>): string {
return message.channelId || DEFAULT_TEXT_CHANNEL_ID;
}
export function getRoomTextChannelIds(room: Room): string[] {
const textChannelIds = (room.channels ?? [])
.filter((channel) => channel.type === 'text')
.map((channel) => channel.id);
return textChannelIds.length > 0 ? textChannelIds : [DEFAULT_TEXT_CHANNEL_ID];
}
export function getRoomById(rooms: Room[], roomId: string): Room | null {
return rooms.find((room) => room.id === roomId) ?? null;
}
export function getChannelLabel(room: Room | null, channelId: string): string {
const channelName = room?.channels?.find((channel) => channel.id === channelId)?.name;
return channelName || DEFAULT_TEXT_CHANNEL_ID;
}
export function getChannelLastReadAt(
settings: NotificationsSettings,
roomId: string,
channelId: string
): number {
return settings.lastReadByChannel[roomId]?.[channelId]
?? settings.roomBaselines[roomId]
?? 0;
}
export function getRoomTrackingBaseline(settings: NotificationsSettings, room: Room): number {
const trackedChannels = getRoomTextChannelIds(room).map((channelId) =>
getChannelLastReadAt(settings, room.id, channelId)
);
return Math.min(...trackedChannels, settings.roomBaselines[room.id] ?? Date.now());
}
export function isRoomMuted(settings: NotificationsSettings, roomId: string): boolean {
return settings.mutedRooms[roomId] === true;
}
export function isChannelMuted(
settings: NotificationsSettings,
roomId: string,
channelId: string
): boolean {
return settings.mutedChannels[roomId]?.[channelId] === true;
}
export function isMessageVisibleInActiveView(
message: Pick<Message, 'channelId' | 'roomId'>,
context: NotificationDeliveryContext
): boolean {
return context.currentRoomId === message.roomId
&& context.activeChannelId === resolveMessageChannelId(message)
&& context.isWindowFocused
&& context.isDocumentVisible;
}
export function shouldDeliverNotification(
settings: NotificationsSettings,
message: Pick<Message, 'channelId' | 'roomId'>,
context: NotificationDeliveryContext
): boolean {
const channelId = resolveMessageChannelId(message);
if (!settings.enabled) {
return false;
}
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') {
return false;
}
if (isRoomMuted(settings, message.roomId) || isChannelMuted(settings, message.roomId, channelId)) {
return false;
}
return !isMessageVisibleInActiveView(message, context);
}
export function buildNotificationDisplayPayload(
message: Pick<Message, 'channelId' | 'content' | 'senderName'>,
room: Room | null,
settings: NotificationsSettings,
requestAttention: boolean
): NotificationDisplayPayload {
const channelId = resolveMessageChannelId(message);
const roomName = room?.name || 'Server';
const channelLabel = getChannelLabel(room, channelId);
return {
title: `${roomName} · #${channelLabel}`,
body: settings.showPreview
? formatMessagePreview(message.senderName, message.content)
: `${message.senderName} sent a new message`,
requestAttention
};
}
export function calculateUnreadForRoom(
room: Room,
messages: Message[],
settings: NotificationsSettings,
currentUserIds: Set<string>
): RoomUnreadCounts {
const channelCounts = Object.fromEntries(
getRoomTextChannelIds(room).map((channelId) => [channelId, 0])
) as Record<string, number>;
for (const message of messages) {
if (message.isDeleted || currentUserIds.has(message.senderId)) {
continue;
}
const channelId = resolveMessageChannelId(message);
if (message.timestamp <= getChannelLastReadAt(settings, room.id, channelId)) {
continue;
}
channelCounts[channelId] = (channelCounts[channelId] ?? 0) + 1;
}
return {
channelCounts,
roomCount: Object.values(channelCounts).reduce((total, count) => total + count, 0)
};
}
function formatMessagePreview(senderName: string, content: string): string {
const normalisedContent = content.replace(/\s+/g, ' ').trim();
if (!normalisedContent) {
return `${senderName} sent a new message`;
}
const preview = normalisedContent.length > MESSAGE_PREVIEW_LIMIT
? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}`
: normalisedContent;
return `${senderName}: ${preview}`;
}

View File

@@ -0,0 +1,64 @@
import type {
Message,
Room,
User
} from '../../../shared-kernel';
export interface NotificationsSettings {
enabled: boolean;
showPreview: boolean;
respectBusyStatus: boolean;
mutedRooms: Record<string, boolean>;
mutedChannels: Record<string, Record<string, boolean>>;
roomBaselines: Record<string, number>;
lastReadByChannel: Record<string, Record<string, number>>;
}
export interface NotificationsUnreadState {
roomCounts: Record<string, number>;
channelCounts: Record<string, Record<string, number>>;
}
export interface NotificationDeliveryContext {
activeChannelId: string | null;
currentRoomId: string | null;
currentUser: User | null;
isDocumentVisible: boolean;
isWindowFocused: boolean;
rooms: Room[];
}
export interface NotificationDisplayPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface RoomUnreadCounts {
channelCounts: Record<string, number>;
roomCount: number;
}
export function createDefaultNotificationSettings(): NotificationsSettings {
return {
enabled: true,
showPreview: true,
respectBusyStatus: true,
mutedRooms: {},
mutedChannels: {},
roomBaselines: {},
lastReadByChannel: {}
};
}
export function createEmptyUnreadState(): NotificationsUnreadState {
return {
roomCounts: {},
channelCounts: {}
};
}
export type NotificationMessage = Pick<
Message,
'channelId' | 'content' | 'id' | 'isDeleted' | 'roomId' | 'senderId' | 'senderName' | 'timestamp'
>;