| 1 | import type { MessageKind, RoomSummary, TimelineItem } from './types'; |
| 2 | |
| 3 | const RECENT_MS = 5 * 60 * 1000; |
| 4 | |
| 5 | export interface NotificationSource { |
| 6 | senderId: string | null; |
| 7 | senderName: string | null; |
| 8 | senderAvatar: string | null; |
| 9 | kind: MessageKind | null; |
| 10 | body: string | null; |
| 11 | undecryptable: boolean; |
| 12 | highlight: boolean; |
| 13 | } |
| 14 | |
| 15 | export interface NotificationPlan { |
| 16 | title: string; |
| 17 | body: string; |
| 18 | tag: string; |
| 19 | renotify: boolean; |
| 20 | roomId: string; |
| 21 | iconMxc: string | null; |
| 22 | } |
| 23 | |
| 24 | export function notifyingCount(room: RoomSummary): number { |
| 25 | if (room.space || room.membership !== 'Joined' || room.notification_mode === 'Mute') return 0; |
| 26 | if (room.notification_mode === 'MentionsAndKeywordsOnly') return room.unread_mentions; |
| 27 | return room.unread_messages + room.unread_mentions; |
| 28 | } |
| 29 | |
| 30 | export function badgeTotal(rooms: RoomSummary[]): number { |
| 31 | return rooms.reduce((sum, room) => sum + notifyingCount(room), 0); |
| 32 | } |
| 33 | |
| 34 | export function roomTag(roomId: string): string { |
| 35 | return `room:${roomId}`; |
| 36 | } |
| 37 | |
| 38 | export interface Arrival { |
| 39 | mention: boolean; |
| 40 | } |
| 41 | |
| 42 | export function arrivalIn(previous: RoomSummary | undefined, next: RoomSummary, now: number): Arrival | null { |
| 43 | if (!previous || notifyingCount(next) <= notifyingCount(previous)) return null; |
| 44 | const at = next.latest_event_timestamp_ms; |
| 45 | if (at !== null && now - at > RECENT_MS) return null; |
| 46 | return { mention: next.unread_mentions > previous.unread_mentions }; |
| 47 | } |
| 48 | |
| 49 | export function isRoomWatched(roomId: string, openRoomId: string | null, visible: boolean, focused: boolean): boolean { |
| 50 | return visible && focused && roomId === openRoomId; |
| 51 | } |
| 52 | |
| 53 | export function sourceFromTimeline(items: TimelineItem[] | undefined): NotificationSource | null { |
| 54 | if (!items) return null; |
| 55 | for (let i = items.length - 1; i >= 0; i--) { |
| 56 | const item = items[i]; |
| 57 | if (item.kind !== 'event') continue; |
| 58 | const { event } = item; |
| 59 | if (event.own) return null; |
| 60 | const content = event.content; |
| 61 | const common = { |
| 62 | senderId: event.sender.user_id, |
| 63 | senderName: event.sender.display_name, |
| 64 | senderAvatar: event.sender.avatar |
| 65 | }; |
| 66 | if (content.kind === 'Message') { |
| 67 | return { ...common, kind: content.message.kind, body: content.message.body, undecryptable: false, highlight: event.highlight === true }; |
| 68 | } |
| 69 | if (content.kind === 'Sticker') return { ...common, kind: null, body: content.body, undecryptable: false, highlight: event.highlight === true }; |
| 70 | if (content.kind === 'UnableToDecrypt') return { ...common, kind: null, body: null, undecryptable: true, highlight: false }; |
| 71 | } |
| 72 | return null; |
| 73 | } |
| 74 | |
| 75 | export function sourceFromSummary(room: RoomSummary): NotificationSource | null { |
| 76 | const latest = room.latest_event; |
| 77 | if (!latest || latest.send_state.kind !== 'Sent') return null; |
| 78 | const hero = room.heroes.find((h) => h.user_id === latest.sender); |
| 79 | return { |
| 80 | senderId: latest.sender, |
| 81 | senderName: hero?.display_name ?? null, |
| 82 | senderAvatar: hero?.avatar ?? null, |
| 83 | kind: latest.kind, |
| 84 | body: latest.body, |
| 85 | undecryptable: false, |
| 86 | highlight: false |
| 87 | }; |
| 88 | } |
| 89 | |
| 90 | const MEDIA_LABEL: Partial<Record<string, string>> = { |
| 91 | Image: 'Sent an image', |
| 92 | Video: 'Sent a video', |
| 93 | Audio: 'Sent an audio message', |
| 94 | File: 'Sent a file', |
| 95 | Location: 'Shared a location' |
| 96 | }; |
| 97 | |
| 98 | function previewText(source: NotificationSource, sender: string): string | null { |
| 99 | if (source.undecryptable) return null; |
| 100 | const kind = typeof source.kind === 'string' ? source.kind : null; |
| 101 | const label = kind ? MEDIA_LABEL[kind] : undefined; |
| 102 | const text = source.body?.trim(); |
| 103 | if (label) return label; |
| 104 | if (!text) return null; |
| 105 | return kind === 'Emote' ? `${sender} ${text}` : text; |
| 106 | } |
| 107 | |
| 108 | function localPart(userId: string): string { |
| 109 | const name = userId.startsWith('@') ? userId.slice(1) : userId; |
| 110 | const colon = name.indexOf(':'); |
| 111 | return colon > 0 ? name.slice(0, colon) : name; |
| 112 | } |
| 113 | |
| 114 | export function planNotification( |
| 115 | room: RoomSummary, |
| 116 | source: NotificationSource | null, |
| 117 | arrival: Arrival |
| 118 | ): NotificationPlan { |
| 119 | const sender = source?.senderName ?? (source?.senderId ? localPart(source.senderId) : null); |
| 120 | const text = source && sender ? previewText(source, sender) : null; |
| 121 | const generic = room.direct ? 'New message' : 'New messages'; |
| 122 | let body: string; |
| 123 | if (!text) body = sender && !room.direct ? `${sender}: ${generic}` : generic; |
| 124 | else body = room.direct || !sender || source?.kind === 'Emote' ? text : `${sender}: ${text}`; |
| 125 | return { |
| 126 | title: room.direct && sender ? sender : room.name, |
| 127 | body, |
| 128 | tag: roomTag(room.room_id), |
| 129 | renotify: arrival.mention || source?.highlight === true, |
| 130 | roomId: room.room_id, |
| 131 | iconMxc: (room.direct ? source?.senderAvatar : null) ?? room.avatar |
| 132 | }; |
| 133 | } |