1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2// See the LICENCE file in the repository root for full licence text.
3
4import { NotificationStackJson } from 'interfaces/notification-json';
5import { action, computed, makeObservable, observable } from 'mobx';
6import Notification from 'models/notification';
7import { Name } from 'models/notification-type';
8import { NotificationCursor } from 'notifications/notification-cursor';
9import NotificationDeletable from 'notifications/notification-deletable';
10import { NotificationIdentity } from 'notifications/notification-identity';
11import NotificationReadable from 'notifications/notification-readable';
12import { NotificationResolver } from 'notifications/notification-resolver';
13import { NotificationContextData } from 'notifications-context';
14
15export default class NotificationStack implements NotificationReadable, NotificationDeletable {
16 @observable cursor: NotificationCursor | null = null;
17 @observable displayOrder = 0;
18 @observable isDeleting = false;
19 @observable isLoading = false;
20 @observable isMarkingAsRead = false;
21 @observable notifications = new Map<number, Notification>();
22 @observable total = 0;
23
24 // This is for cases when there are more notifications available to be loaded
25 // but all the currently loaded notifications have seen removed, allowing the last known notification
26 // to be shown in the stack's header instead of nothing.
27 @observable private lastNotification: Notification | null = null;
28
29 @computed
30 get canMarkAsRead() {
31 for (const [, notifications] of this.notifications) {
32 if (notifications.canMarkRead) return true;
33 }
34
35 return false;
36 }
37
38 @computed
39 get first() {
40 return this.orderedNotifications[0] ?? this.lastNotification;
41 }
42
43 @computed
44 get hasMore() {
45 return !(this.notifications.size >= this.total || this.cursor == null);
46 }
47
48 @computed
49 get hasVisibleNotifications() {
50 return this.notifications.size > 0;
51 }
52
53 @computed
54 get id() {
55 return `${this.objectType}-${this.objectId}-${this.category}`;
56 }
57
58 get identity(): NotificationIdentity {
59 return {
60 category: this.category,
61 objectId: this.objectId,
62 objectType: this.objectType,
63 };
64 }
65
66 @computed
67 get isSingle() {
68 return this.total === 1;
69 }
70
71 @computed
72 get orderedNotifications() {
73 return [...this.notifications.values()].sort((x, y) => y.id - x.id);
74 }
75
76 get type() {
77 return this.objectType;
78 }
79
80 constructor(
81 readonly objectId: number,
82 readonly objectType: Name,
83 readonly category: string,
84 readonly resolver: NotificationResolver,
85 ) {
86 makeObservable(this);
87 }
88
89 static fromJson(json: NotificationStackJson, resolver: NotificationResolver) {
90 const obj = new NotificationStack(json.object_id, json.object_type, json.category, resolver);
91 obj.updateWithJson(json);
92 return obj;
93 }
94
95 @action
96 add(notification: Notification) {
97 this.notifications.set(notification.id, notification);
98 this.displayOrder = Math.max(notification.id, this.displayOrder);
99 }
100
101 @action
102 delete() {
103 this.resolver.delete(this);
104 }
105
106 @action
107 deleteItem(notification?: Notification) {
108 // not from this stack, ignore.
109 if (notification == null || !this.notifications.has(notification.id)) return;
110 this.resolver.delete(notification);
111 }
112
113 @action
114 loadMore(context: NotificationContextData) {
115 if (this.cursor == null) return;
116
117 this.isLoading = true;
118
119 this.resolver.loadMore(this.identity, context, this.cursor)
120 .always(action(() => {
121 this.isLoading = false;
122 }));
123 }
124
125 @action
126 markAsRead(notification?: Notification) {
127 // not from this stack, ignore.
128 if (notification == null || !this.notifications.has(notification.id)) return;
129 this.resolver.queueMarkAsRead(notification);
130 }
131
132 @action
133 markStackAsRead() {
134 this.resolver.queueMarkAsRead(this);
135 }
136
137 @action
138 remove(notification: Notification) {
139 if (this.notifications.size === 1) {
140 // doesn't matter if the notification that's being removed doesn't actually exist in the stack,
141 // this can still be set.
142 this.lastNotification = this.notifications.values().next().value;
143 }
144
145 const existed = this.notifications.delete(notification.id);
146 if (existed) this.total--;
147 return existed;
148 }
149
150 @action
151 updateWithJson(json: NotificationStackJson) {
152 this.cursor = json.cursor;
153 this.total = json.total;
154 }
155}
156
157export function idFromJson(json: NotificationStackJson) {
158 return `${json.object_type}-${json.object_id}-${json.category}`;
159}