Skip to content

Commit 95d068b

Browse files
committed
Add support for system notifications when game and tab are unfocused
1 parent 6bce419 commit 95d068b

File tree

8 files changed

+199
-68
lines changed

8 files changed

+199
-68
lines changed

src/client/java/dev/creesch/config/ModConfig.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ public class ModConfig {
3333
@SerialEntry(comment = "Enable ping on username")
3434
public boolean pingOnUsername = true;
3535

36+
@SerialEntry(comment = "Enable system notifications")
37+
public boolean systemNotifications = false;
38+
3639
@SerialEntry(comment = "Extra ping keywords")
3740
public List<String> pingKeywords = Arrays.asList();
3841

src/client/java/dev/creesch/config/ModConfigScreen.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,30 @@ public static Screen createScreen(Screen parent) {
4545
.controller(BooleanControllerBuilder::create)
4646
.build()
4747
)
48+
.option(
49+
Option.<Boolean>createBuilder()
50+
.name(Text.literal("System Notifications"))
51+
.description(
52+
OptionDescription.of(
53+
Text.literal(
54+
"Send system notifications when pinged.\n" +
55+
"Notifications are only sent when the game and chat browser tab are unfocused."
56+
)
57+
)
58+
)
59+
.binding(
60+
ModConfig.HANDLER.defaults()
61+
.systemNotifications,
62+
() ->
63+
ModConfig.HANDLER.instance()
64+
.systemNotifications,
65+
val ->
66+
ModConfig.HANDLER.instance()
67+
.systemNotifications = val
68+
)
69+
.controller(BooleanControllerBuilder::create)
70+
.build()
71+
)
4872
.build()
4973
)
5074
.group(

src/client/java/dev/creesch/model/ChatMessagePayload.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ public class ChatMessagePayload {
1414
private JsonObject component;
1515
private Map<String, String> translations;
1616
private boolean isPing;
17+
private boolean notify;
1718
}

src/client/java/dev/creesch/model/WebsocketMessageBuilder.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ public static WebsocketJsonMessage createLiveChatMessage(
7979
(timestamp + minecraftChatJson).getBytes()
8080
).toString();
8181

82+
boolean ping = !fromSelf && isPing(message, client);
83+
boolean notify = ping && doNotify(client);
8284
// Back to objects we go
8385
JsonObject jsonObject = gson.fromJson(
8486
minecraftChatJson,
@@ -88,7 +90,8 @@ public static WebsocketJsonMessage createLiveChatMessage(
8890
.history(false)
8991
.uuid(messageUUID)
9092
.component(jsonObject)
91-
.isPing(!fromSelf && isPing(message, client))
93+
.isPing(ping)
94+
.notify(notify)
9295
.translations(translations)
9396
.build();
9497

@@ -163,6 +166,17 @@ private static Pattern pingPattern(String pingKeyword) {
163166
);
164167
}
165168

169+
/**
170+
* Whether the web clients should send a system notification.
171+
*/
172+
private static boolean doNotify(MinecraftClient client) {
173+
if (client.isWindowFocused()) {
174+
return false;
175+
}
176+
177+
return ModConfig.HANDLER.instance().systemNotifications;
178+
}
179+
166180
/**
167181
* Processes both chat and game messages, converting them to the appropriate format.
168182
*/

src/client/resources/web/js/chat.mjs

Lines changed: 76 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { directMessageManager } from './managers/direct_message.mjs';
1414
import { parseModServerMessage } from './messages/message_types.mjs';
1515
import { faviconManager } from './managers/favicon_manager.mjs';
1616
import { tabListManager } from './managers/tab_list_manager.mjs';
17+
import { notificationManager } from './managers/notification_manager.mjs';
1718

1819
/**
1920
* Import all types we might need
@@ -162,6 +163,19 @@ loadMoreButtonElement.addEventListener('click', () => {
162163
}
163164
});
164165

166+
async function enableNotificationsOnClick() {
167+
if (await notificationManager.enableNotifications()) {
168+
console.log('Notifications enabled');
169+
} else {
170+
console.log('Notifications not enabled');
171+
}
172+
173+
document.body.removeEventListener('click', enableNotificationsOnClick);
174+
}
175+
if (!notificationManager.isEnabled()) {
176+
document.body.addEventListener('click', enableNotificationsOnClick);
177+
}
178+
165179
/**
166180
* ======================
167181
* Chat related functions
@@ -210,79 +224,76 @@ function handleChatMessage(message) {
210224
faviconManager.handleNewMessage(message.payload.isPing);
211225
}
212226

213-
requestAnimationFrame(() => {
214-
const messageElement = document.createElement('article');
215-
messageElement.classList.add('message');
227+
const messageElement = document.createElement('article');
228+
messageElement.classList.add('message');
216229

217-
if (message.payload.isPing) {
218-
messageElement.classList.add('ping');
219-
}
220-
221-
// Create timestamp outside of try block. That way errors can be timestamped as well for the moment they did happen.
222-
const { timeString, fullDateTime } = formatTimestamp(message.timestamp);
223-
const timeElement = document.createElement('time');
224-
timeElement.dateTime = new Date(message.timestamp).toISOString();
225-
timeElement.textContent = timeString;
226-
timeElement.title = fullDateTime;
227-
timeElement.className = 'message-time';
228-
messageElement.appendChild(timeElement);
230+
if (message.payload.isPing) {
231+
messageElement.classList.add('ping');
232+
}
229233

230-
try {
231-
// Format the chat message - this uses the Component format from message_parsing
232-
assertIsComponent(message.payload.component);
233-
const chatContent = formatChatMessage(
234-
message.payload.component,
235-
message.payload.translations,
236-
);
237-
messageElement.appendChild(chatContent);
238-
} catch (e) {
239-
console.error(message);
240-
if (e instanceof ComponentError) {
241-
console.error('Invalid component:', e.toString());
242-
messageElement.appendChild(
243-
formatChatMessage(
244-
{
245-
text: 'Invalid message received from server',
246-
color: 'red',
247-
},
248-
{},
249-
),
250-
);
251-
} else {
252-
console.error('Error parsing message:', e);
253-
messageElement.appendChild(
254-
formatChatMessage(
255-
{
256-
text: 'Error parsing message',
257-
color: 'red',
258-
},
259-
{},
260-
),
261-
);
262-
}
234+
// Create timestamp outside of try block. That way errors can be timestamped as well for the moment they did happen.
235+
const { timeString, fullDateTime } = formatTimestamp(message.timestamp);
236+
const timeElement = document.createElement('time');
237+
timeElement.dateTime = new Date(message.timestamp).toISOString();
238+
timeElement.textContent = timeString;
239+
timeElement.title = fullDateTime;
240+
timeElement.className = 'message-time';
241+
messageElement.appendChild(timeElement);
242+
243+
try {
244+
// Format the chat message - this uses the Component format from message_parsing
245+
assertIsComponent(message.payload.component);
246+
const chatContent = formatChatMessage(
247+
message.payload.component,
248+
message.payload.translations,
249+
);
250+
251+
if (message.payload.notify && chatContent.textContent) {
252+
notificationManager.sendNotification(chatContent.textContent);
263253
}
264254

265-
// Storing raw scroll value. To be used to fix the scroll position down the line.
266-
const scrolledFromTop = messagesElement.scrollTop;
267-
268-
if (message.payload.history) {
269-
// Insert the message after the load-more button
270-
loadMoreContainerElement.before(messageElement);
255+
messageElement.appendChild(chatContent);
256+
} catch (e) {
257+
console.error(message);
258+
if (e instanceof ComponentError) {
259+
console.error('Invalid component:', e.toString());
260+
messageElement.appendChild(
261+
formatChatMessage({
262+
text: 'Invalid message received from server',
263+
color: 'red',
264+
}),
265+
);
271266
} else {
272-
// For new messages, insert at the start
273-
messagesElement.insertBefore(
274-
messageElement,
275-
messagesElement.firstChild,
267+
console.error('Error parsing message:', e);
268+
messageElement.appendChild(
269+
formatChatMessage({
270+
text: 'Error parsing message',
271+
color: 'red',
272+
}),
276273
);
277274
}
275+
}
278276

279-
// If it is due to the flex column reverse or something else, once the user has scrolled it doesn't "lock" at the bottom.
280-
// Let's fix that, if the user was near the bottom when a message was inserted we put them back there.
281-
// Note: the values appear negative due to the flex column shenanigans.
282-
if (scrolledFromTop <= 1 && scrolledFromTop >= -35) {
283-
messagesElement.scrollTop = 0;
284-
}
285-
});
277+
// Storing raw scroll value. To be used to fix the scroll position down the line.
278+
const scrolledFromTop = messagesElement.scrollTop;
279+
280+
if (message.payload.history) {
281+
// Insert the message after the load-more button
282+
loadMoreContainerElement.before(messageElement);
283+
} else {
284+
// For new messages, insert at the start
285+
messagesElement.insertBefore(
286+
messageElement,
287+
messagesElement.firstChild,
288+
);
289+
}
290+
291+
// If it is due to the flex column reverse or something else, once the user has scrolled it doesn't "lock" at the bottom.
292+
// Let's fix that, if the user was near the bottom when a message was inserted we put them back there.
293+
// Note: the values appear negative due to the flex column shenanigans.
294+
if (scrolledFromTop <= 1 && scrolledFromTop >= -35) {
295+
messagesElement.scrollTop = 0;
296+
}
286297
}
287298

288299
function clearMessageHistory() {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// @ts-check
2+
'use strict';
3+
4+
/**
5+
* Manages browser notifications for new messages
6+
*/
7+
class NotificationManager {
8+
/**
9+
* @type {boolean}
10+
*/
11+
#notificationsEnabled = false;
12+
13+
constructor() {
14+
// Check if notifications are supported
15+
if (!('Notification' in window)) {
16+
console.warn('This browser does not support notifications');
17+
return;
18+
}
19+
20+
// Check if we already have permission
21+
if (Notification.permission === 'granted') {
22+
this.#notificationsEnabled = true;
23+
}
24+
}
25+
26+
/**
27+
* Enable notifications by requesting permission from the user
28+
* @returns {Promise<boolean>} Whether notifications were successfully enabled
29+
*/
30+
async enableNotifications() {
31+
if (!('Notification' in window)) {
32+
return false;
33+
}
34+
35+
if (Notification.permission === 'granted') {
36+
this.#notificationsEnabled = true;
37+
return true;
38+
}
39+
40+
if (Notification.permission !== 'denied') {
41+
const permission = await Notification.requestPermission();
42+
this.#notificationsEnabled = permission === 'granted';
43+
return this.#notificationsEnabled;
44+
}
45+
46+
return false;
47+
}
48+
49+
/**
50+
* Send a notification if notifications are enabled and the page is not visible
51+
* @param {string} text - The text to display in the notification
52+
*/
53+
sendNotification(text) {
54+
if (!this.#notificationsEnabled) {
55+
return;
56+
}
57+
if (document.visibilityState === 'visible') {
58+
return;
59+
}
60+
61+
new Notification('Minecraft WebChat', {
62+
body: text,
63+
icon: '/img/icon_32.png',
64+
});
65+
}
66+
67+
/**
68+
* Get whether notifications are currently enabled
69+
* @returns {boolean}
70+
*/
71+
isEnabled() {
72+
return this.#notificationsEnabled;
73+
}
74+
}
75+
76+
// Export a singleton instance since we only need one notification manager
77+
export const notificationManager = new NotificationManager();

src/client/resources/web/js/messages/message_parsing.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,10 +1337,10 @@ export function formatPlainText(element) {
13371337
/**
13381338
* Transforms a Minecraft component into HTML.
13391339
* @param {Component} component
1340-
* @param {Record<string, string>} translations Translation key-value pairs
1340+
* @param {Record<string, string>} [translations] Translation key-value pairs
13411341
* @returns {Element | Text}
13421342
*/
1343-
export function formatChatMessage(component, translations) {
1343+
export function formatChatMessage(component, translations = {}) {
13441344
// Message payload should come with translations included. If not it likely is a legacy 1.21.1 message and the fallback translation file is used.
13451345
const usedTranslations = Object.keys(translations).length
13461346
? translations

src/client/resources/web/js/messages/message_types.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
* translations: Record<string, string>,
3131
* uuid: string,
3232
* isPing: boolean,
33+
* notify: boolean,
3334
* }
3435
* }} ChatMessage
3536
*/

0 commit comments

Comments
 (0)