0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/plugins/chat/assets/service-worker/chat-service-worker-extensions.js
Rafael dos Santos Silva b0873d3ecd
FEATURE: Restore web push quick reply for chat messages (#39614)
**Previously**, the "Reply" action on chat web push notifications was
silently broken — the service worker's chat URL regex stopped matching
when chat routes moved to `/chat/c/:slug/:id`, and the chat-specific
code lived in core with no thread support, URL encoding, or error
handling.

**In this update**, generalize the core service worker into a
plugin-extensible action registry, move chat reply handling into the
chat plugin with proper thread/encoding/fallback support, and convert
single-emoji replies into reactions on the source message (falling back
to a regular message post on any failure).

## Architecture

Core (`app/views/static/service-worker.js.erb`) now exposes:

```js
self.registerNotificationActionHandler(action, handler);
```

Push payloads can carry `actions` (Web Notifications API format) and
`action_data` (an arbitrary object surfaced to the handler at
`event.notification.data.actionData`). The click handler dispatches by
`event.action` name and falls back to the existing focus-or-open
behavior when no handler matches or a handler throws.

`PushNotificationPusher` forwards `actions` / `action_data` when
present.

The chat plugin owns its own service worker
(`plugins/chat/assets/javascripts/service-worker.js`), registered via
`register_service_worker`. It registers a `chat-reply` handler that:

1. Fetches a CSRF token.
2. If the reply is a single emoji grapheme (detected via
`Intl.Segmenter`, with a `\p{Extended_Pictographic}` regex fallback),
`PUT`s to `/chat/:channel_id/react/:message_id`.
3. Otherwise — or if the reaction request fails — `POST`s to
`/chat/:channel_id` with proper `URLSearchParams` encoding and the
source `thread_id` if present.
4. If everything fails, opens the channel so the user can retype.

`Chat::Notifier.push_notification_reply_action(chat_message, user)`
builds the payload in the user's locale with `channel_id`, `message_id`,
and optional `thread_id`.

`Chat::MessageReactor#react!` resolves raw Unicode via
`Emoji.unicode_replacements[emoji] || emoji` before validation, so
smartwatch quick-reply chips (which send raw Unicode like `👍🏽` or ZWJ
sequences) just work. Existing shortcode callers are unaffected.

## Why this matters

Smartwatch notification UIs expose a single "reply" affordance with
canned chips that include short text and emoji. The user can't choose
between "send as message" and "react"; they tap a chip and a string
comes through. Treating a single-grapheme pictographic reply as a
reaction matches user intent on the watch UX without any new product
surface.

## Test plan

- [ ] On a chat-enabled site, subscribe to web push notifications.
- [ ] Have another user post a message that mentions you, or a watched
channel message.
- [ ] On the resulting OS notification, confirm a "Reply" action button
appears.
- [ ] Type a text reply and submit — verify it posts as a message in the
right channel/thread.
- [ ] Submit a single emoji (e.g. 👍 or 👍🏽 with skin tone) — verify it
lands as a reaction on the source message instead of posting a message.
- [ ] Submit a ZWJ sequence (e.g. 👨‍👩‍👧) — verify it lands as a reaction
(or falls back to message post if not in the emoji DB).
- [ ] Sign out / let session expire and submit a reply — verify the
channel opens as a fallback rather than silently failing.
- [ ] Watch via Wear OS / watchOS quick-reply chips and confirm the same
behavior end-to-end.
2026-04-29 12:36:03 -03:00

168 lines
5 KiB
JavaScript
Vendored

// Registers a "chat-reply" handler for the Web Notifications API "Reply"
// quick-action button on chat push notifications. Invoked by the core
// service worker (app/views/static/service-worker.js.erb) when the user
// types a reply and submits the action button on the notification.
//
// If the reply is a single emoji grapheme (typical of smartwatch quick
// reply chips), it is sent to the chat reaction endpoint instead of
// posting a one-character message in the channel. Reaction failures
// transparently fall back to a normal message post so a user's tap is
// never lost.
//
// The matching action button and its action_data payload are produced
// server-side by Chat::Notifier.push_notification_reply_action.
// Matches a single base pictographic codepoint, optionally followed by
// a variation selector (U+FE0F) or skin tone (U+1F3FB..U+1F3FF), plus
// zero or more ZWJ-joined (U+200D) parts. Used as a fallback when
// Intl.Segmenter is unavailable.
const SINGLE_EMOJI_FALLBACK_RE =
/^(?:\p{Extended_Pictographic}(?:\u{FE0F}|[\u{1F3FB}-\u{1F3FF}])?)(?:\u{200D}\p{Extended_Pictographic}(?:\u{FE0F}|[\u{1F3FB}-\u{1F3FF}])?)*$/u;
function isSingleEmoji(text) {
if (!text) {
return false;
}
if (typeof Intl !== "undefined" && Intl.Segmenter) {
const iter = new Intl.Segmenter(undefined, {
granularity: "grapheme",
})
.segment(text)
[Symbol.iterator]();
const first = iter.next();
if (first.done || !iter.next().done) {
return false;
}
return /\p{Extended_Pictographic}/u.test(first.value.segment);
}
return SINGLE_EMOJI_FALLBACK_RE.test(text);
}
function fetchCsrfToken(baseUrl) {
return fetch(baseUrl + "/session/csrf", {
credentials: "include",
headers: { Accept: "application/json" },
})
.then(function (response) {
if (!response.ok) {
throw new Error("CSRF fetch failed: " + response.status);
}
return response.json();
})
.then(function (json) {
return json.csrf;
});
}
function postChatMessage({ baseUrl, csrf, channelId, threadId, message }) {
const body = new URLSearchParams();
body.set("message", message);
if (threadId) {
body.set("thread_id", String(threadId));
}
return fetch(baseUrl + "/chat/" + encodeURIComponent(channelId), {
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-CSRF-Token": csrf,
Accept: "application/json",
},
body: body.toString(),
method: "POST",
}).then(function (response) {
if (!response.ok) {
throw new Error("Chat reply POST failed: " + response.status);
}
return response;
});
}
function reactToChatMessage({ baseUrl, csrf, channelId, messageId, emoji }) {
const body = new URLSearchParams();
body.set("react_action", "add");
body.set("emoji", emoji);
return fetch(
baseUrl +
"/chat/" +
encodeURIComponent(channelId) +
"/react/" +
encodeURIComponent(messageId),
{
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-CSRF-Token": csrf,
Accept: "application/json",
},
body: body.toString(),
method: "PUT",
}
).then(function (response) {
if (!response.ok) {
throw new Error("Chat reaction PUT failed: " + response.status);
}
return response;
});
}
self.registerNotificationActionHandler("chat-reply", function (event) {
const data = event.notification.data || {};
const actionData = data.actionData || {};
const baseUrl = data.baseUrl || "";
const fallbackUrl = data.url || "";
const reply = (event.reply || "").trim();
function openFallback() {
if (!fallbackUrl || !self.clients || !self.clients.openWindow) {
return Promise.resolve();
}
return self.clients.openWindow(baseUrl + fallbackUrl);
}
if (!actionData.channel_id || !reply) {
return openFallback();
}
const tryReact =
actionData.message_id && isSingleEmoji(reply)
? function (csrf) {
return reactToChatMessage({
baseUrl,
csrf,
channelId: actionData.channel_id,
messageId: actionData.message_id,
emoji: reply,
}).catch(function (err) {
// eslint-disable-next-line no-console
console.warn("Chat reaction failed, falling back to message", err);
return postChatMessage({
baseUrl,
csrf,
channelId: actionData.channel_id,
threadId: actionData.thread_id,
message: reply,
});
});
}
: function (csrf) {
return postChatMessage({
baseUrl,
csrf,
channelId: actionData.channel_id,
threadId: actionData.thread_id,
message: reply,
});
};
return fetchCsrfToken(baseUrl)
.then(tryReact)
.catch(function (err) {
// eslint-disable-next-line no-console
console.error("Chat quick reply failed", err);
return openFallback();
});
});