atagen

malice

public
Star0
Watch0
85334efe0e3a895b34f872e42c0caab6b87cd252
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/notification-model.tsTypescript133 lines4.6 KB
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
}