mirror of
https://github.com/discourse/discourse.git
synced 2026-08-08 17:53:55 +08:00
**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.
168 lines
5 KiB
JavaScript
Vendored
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();
|
|
});
|
|
});
|