From 4e988352b04d88b322cb1c99d366f77939867193 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Mon, 20 Jul 2026 12:58:55 +0200 Subject: [PATCH 1/7] Test page --- .../push_notifications/pos-module.json | 2 +- .../pages/_push_notifications/index.liquid | 67 +++++++++++++++++++ .../pages/_push_notifications/send.liquid | 29 ++++++++ .../push_notifications/template-values.json | 2 +- pos-module-push-notifications/pos-module.json | 2 +- 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 pos-module-push-notifications/modules/push_notifications/public/views/pages/_push_notifications/index.liquid create mode 100644 pos-module-push-notifications/modules/push_notifications/public/views/pages/_push_notifications/send.liquid diff --git a/pos-module-push-notifications/modules/push_notifications/pos-module.json b/pos-module-push-notifications/modules/push_notifications/pos-module.json index 2360a8c..2adf4cb 100644 --- a/pos-module-push-notifications/modules/push_notifications/pos-module.json +++ b/pos-module-push-notifications/modules/push_notifications/pos-module.json @@ -1,6 +1,6 @@ { "machine_name": "push_notifications", - "version": "1.0.0", + "version": "1.0.1", "name": "Push Notifications", "dependencies": { "core": "^2.1.9", diff --git a/pos-module-push-notifications/modules/push_notifications/public/views/pages/_push_notifications/index.liquid b/pos-module-push-notifications/modules/push_notifications/public/views/pages/_push_notifications/index.liquid new file mode 100644 index 0000000..ccc3c09 --- /dev/null +++ b/pos-module-push-notifications/modules/push_notifications/public/views/pages/_push_notifications/index.liquid @@ -0,0 +1,67 @@ +--- +slug: _push_notifications +method: get +layout: '' +--- +{% doc %} + Dev/staging-only page to send a real test push notification to the current + user's own active subscriptions. Mirrors the `/_tests` convention used by + pos-module-tests (same env gate, same "blank outside dev/staging" behavior) + as its own independent namespace — this module has no dependency on the + tests module. +{% enddoc %} +{% if context.environment == 'staging' or context.environment == 'development' %} + {% assign queued = context.params.queued %} + {% if context.current_user != blank %} + {% function subs = 'modules/push_notifications/queries/subscriptions/search', user_id: context.current_user.id, active: true, limit: 1, page: 1 %} + {% endif %} + + + + + Push Notifications — Test + + + +

Push Notifications — Test

+ + {% if context.current_user == blank %} +

Log in to send a test push notification.

+ {% else %} + {% if queued != blank %} +

Queued for {{ queued }} subscription(s).

+ {% endif %} + + {% if subs.total_entries == 0 %} +

You have no active subscriptions yet — subscribe first, then come back here.

+ {% else %} +

You have {{ subs.total_entries }} active subscription(s).

+ {% endif %} + +
+ + + + + + + + + + + + +
+ {% endif %} + + +{% endif %} diff --git a/pos-module-push-notifications/modules/push_notifications/public/views/pages/_push_notifications/send.liquid b/pos-module-push-notifications/modules/push_notifications/public/views/pages/_push_notifications/send.liquid new file mode 100644 index 0000000..1b45588 --- /dev/null +++ b/pos-module-push-notifications/modules/push_notifications/public/views/pages/_push_notifications/send.liquid @@ -0,0 +1,29 @@ +--- +slug: _push_notifications +method: post +--- +{% doc %} + Handles the test-notification form from `_push_notifications/index.liquid`: + broadcasts a test push to the current user's own active subscriptions and + redirects back with the queued count. +{% enddoc %} +{% liquid + unless context.environment == 'staging' or context.environment == 'development' + return + endunless + + if context.current_user == blank + redirect_to '/sessions/new' + return + endif + + assign title = context.params.title | default: 'Test notification' + assign body = context.params.body | default: 'This is a test push notification.' + assign url = context.params.url | default: '/' + assign payload = { "title": title, "body": body, "url": url } + + function result = 'modules/push_notifications/commands/notifications/broadcast', user_ids: [context.current_user.id], payload: payload, ttl: null, urgency: null + + assign redirect_url = '/_push_notifications?queued=' | append: result.queued + redirect_to redirect_url +%} diff --git a/pos-module-push-notifications/modules/push_notifications/template-values.json b/pos-module-push-notifications/modules/push_notifications/template-values.json index f492660..447bc70 100644 --- a/pos-module-push-notifications/modules/push_notifications/template-values.json +++ b/pos-module-push-notifications/modules/push_notifications/template-values.json @@ -2,7 +2,7 @@ "name": "Push Notifications", "machine_name": "push_notifications", "type": "module", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "core": "^2.1.9", "common-styling": "^1.38.0", diff --git a/pos-module-push-notifications/pos-module.json b/pos-module-push-notifications/pos-module.json index 4879367..21ca7ab 100644 --- a/pos-module-push-notifications/pos-module.json +++ b/pos-module-push-notifications/pos-module.json @@ -1,7 +1,7 @@ { "machine_name": "push_notifications", "name": "Push Notifications", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "core": "^2.1.9", "common-styling": "^1.38.0", From cdc014f81edab739a9e5e5f7ca71bc7cb1d1f03d Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 22 Jul 2026 09:39:17 +0200 Subject: [PATCH 2/7] Chat push notifications --- pos-module-chat/CLAUDE.md | 7 +++- .../send_push_notification.liquid | 42 +++++++++++++++++++ .../modules/chat/template-values.json | 5 ++- pos-module-chat/pos-module.json | 5 ++- 4 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/send_push_notification.liquid diff --git a/pos-module-chat/CLAUDE.md b/pos-module-chat/CLAUDE.md index 1ff6f4f..facc17d 100644 --- a/pos-module-chat/CLAUDE.md +++ b/pos-module-chat/CLAUDE.md @@ -6,7 +6,7 @@ This is the **pos-module-chat** module. The monorepo-wide conventions (command p ## What this module does -Real-time bi-directional chat over WebSockets (Rails Action Cable, via the `actioncable` npm client). Depends on `core`, `user`, `common-styling` (see `pos-module.json`). Profile module is also expected at runtime (participants are profiles). +Real-time bi-directional chat over WebSockets (Rails Action Cable, via the `actioncable` npm client). Depends on `core`, `user`, `common-styling`, `push_notifications` (see `pos-module.json`). Profile module is also expected at runtime (participants are profiles). The distributed part is `modules/chat/` only. `app/` is a non-distributed example app (permissions overwrite, layout wiring, import map) and `tests/` is the E2E suite — neither ships when a consumer runs `pos-cli modules install chat`. @@ -59,7 +59,10 @@ Access is gated by the `chat.inbox` permission via `modules/user/helpers/can_do_ ## Events -`messages/create/execute` publishes a `chat_message_created` event (`message_id`, `app_host`). Consumed by `lib/consumers/chat_message_created/notify_of_new_message.liquid` for email notification. Event payloads are validated by `lib/events/chat_message_created.liquid`. +`messages/create/execute` publishes a `chat_message_created` event (`message_id`, `app_host`), validated by `lib/events/chat_message_created.liquid`. It has three consumers: + +- `lib/consumers/chat_message_created/broadcast_new_message.liquid` — broadcasts immediately to every other participant's `notifications-` WebSocket room (real-time in-app notification). +- `lib/consumers/chat_message_created/send_push_notification.liquid` — sends a Web Push notification immediately (via `pos-module-push-notifications`, a required dependency) to every other participant, one per message (no debounce/batching by design). ## Testing diff --git a/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/send_push_notification.liquid b/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/send_push_notification.liquid new file mode 100644 index 0000000..1b8fffa --- /dev/null +++ b/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/send_push_notification.liquid @@ -0,0 +1,42 @@ +{% comment %} + Consumer for the `chat_message_created` event. + + Sends a Web Push notification (via pos-module-push-notifications) to every + other conversation participant immediately, one per message. Participant + ids on a conversation are profile ids, but push_notifications' broadcast + command is keyed by platformOS user id, so each participant profile is + resolved to its `user_id` before calling broadcast. Runs as a background + job with retries, matching broadcast_new_message.liquid. +{% endcomment %} +{% background message_id: event.message_id, app_host: event.app_host, max_attempts: 5 %} + {% liquid + graphql messages = 'modules/chat/messages/search', id: message_id + assign message = messages.messages.results.first + + if message + function sender = 'modules/user/queries/profiles/find', id: message.autor_id, user_id: null, uuid: null, first_name: null, last_name: null + assign sender_name = sender.name | default: sender.first_name + + assign recipient_user_ids = [] + for participant_id in message.conversation.participant_ids + unless participant_id == message.autor_id + function recipient = 'modules/user/queries/profiles/find', id: participant_id, user_id: null, uuid: null, first_name: null, last_name: null + if recipient.user_id + assign recipient_user_ids << recipient.user_id + endif + endunless + endfor + + if recipient_user_ids.size > 0 + assign title = 'New message from ' | append: sender_name + assign body = message.message | truncate: 120 + assign url = 'https://' | append: app_host | append: '/inbox?conversation_id=' | append: message.conversation_id + assign payload = { "title": title, "body": body, "url": url } + + function _result = 'modules/push_notifications/commands/notifications/broadcast', user_ids: recipient_user_ids, payload: payload, ttl: null, urgency: null + endif + endif + %} +{% endbackground %} + +{% return 'scheduled' %} diff --git a/pos-module-chat/modules/chat/template-values.json b/pos-module-chat/modules/chat/template-values.json index 9b6bbd2..07150dc 100644 --- a/pos-module-chat/modules/chat/template-values.json +++ b/pos-module-chat/modules/chat/template-values.json @@ -2,10 +2,11 @@ "name": "Pos Module Chat", "machine_name": "chat", "type": "module", - "version": "2.0.1", + "version": "2.1.0", "dependencies": { "core": "^2.1.9", "user": "^5.2.10", - "common-styling": "^1.11.0" + "common-styling": "^1.11.0", + "push_notifications": "^1.0.1" } } diff --git a/pos-module-chat/pos-module.json b/pos-module-chat/pos-module.json index b0cbf77..ab8ede3 100644 --- a/pos-module-chat/pos-module.json +++ b/pos-module-chat/pos-module.json @@ -2,9 +2,10 @@ "dependencies": { "core": "^2.1.9", "user": "^5.2.10", - "common-styling": "^1.37.30" + "common-styling": "^1.37.30", + "push_notifications": "^1.0.1" }, "machine_name": "chat", "name": "Pos Module Chat", - "version": "2.0.1" + "version": "2.1.0" } \ No newline at end of file From ece36b892622db04fbbff20971c168aabf190af0 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 22 Jul 2026 09:50:09 +0200 Subject: [PATCH 3/7] Cleanup --- .../modules/chat/public/views/partials/inbox.liquid | 4 ++-- .../modules/chat/public/views/partials/search.liquid | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pos-module-chat/modules/chat/public/views/partials/inbox.liquid b/pos-module-chat/modules/chat/public/views/partials/inbox.liquid index ddfcc82..f0e163a 100644 --- a/pos-module-chat/modules/chat/public/views/partials/inbox.liquid +++ b/pos-module-chat/modules/chat/public/views/partials/inbox.liquid @@ -76,7 +76,7 @@ {% if conversations.total_entries == 0 %}
- {% render 'modules/common-styling/icon', icon: 'messageBubblesDouble' %} + {% render 'modules/common-styling/icon', icon: 'messageBubblesDouble', class: null %}

{{ 'modules/chat/blank.title' | t }}

{{ 'modules/chat/blank.description.mobile' | t }}
@@ -88,7 +88,7 @@
Show conversations list - {% render 'modules/common-styling/icon', icon: 'dashLeft' %} + {% render 'modules/common-styling/icon', icon: 'dashLeft', class: null %} {% liquid assign participants = current_conversation.participants diff --git a/pos-module-chat/modules/chat/public/views/partials/search.liquid b/pos-module-chat/modules/chat/public/views/partials/search.liquid index 2c0efeb..5b4f7bb 100644 --- a/pos-module-chat/modules/chat/public/views/partials/search.liquid +++ b/pos-module-chat/modules/chat/public/views/partials/search.liquid @@ -1,9 +1,9 @@ From 2f77aa26205563dfb4247df00e898e4d8adf3b49 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 22 Jul 2026 10:52:19 +0200 Subject: [PATCH 4/7] Delayed notifications --- pos-module-chat/CLAUDE.md | 8 +++++-- .../public/graphql/messages/search.graphql | 1 + .../broadcast_new_message.liquid | 8 +++---- .../notify_of_new_message.liquid | 2 +- .../send_push_notification.liquid | 22 +++++++++++++------ 5 files changed, 27 insertions(+), 14 deletions(-) rename pos-module-chat/modules/chat/public/lib/consumers/{chat_message_created => message_notification_to_send}/send_push_notification.liquid (59%) diff --git a/pos-module-chat/CLAUDE.md b/pos-module-chat/CLAUDE.md index facc17d..afdaca8 100644 --- a/pos-module-chat/CLAUDE.md +++ b/pos-module-chat/CLAUDE.md @@ -59,10 +59,14 @@ Access is gated by the `chat.inbox` permission via `modules/user/helpers/can_do_ ## Events -`messages/create/execute` publishes a `chat_message_created` event (`message_id`, `app_host`), validated by `lib/events/chat_message_created.liquid`. It has three consumers: +`messages/create/execute` publishes a `chat_message_created` event (`message_id`, `app_host`), validated by `lib/events/chat_message_created.liquid`. It has two consumers: - `lib/consumers/chat_message_created/broadcast_new_message.liquid` — broadcasts immediately to every other participant's `notifications-` WebSocket room (real-time in-app notification). -- `lib/consumers/chat_message_created/send_push_notification.liquid` — sends a Web Push notification immediately (via `pos-module-push-notifications`, a required dependency) to every other participant, one per message (no debounce/batching by design). +- `lib/consumers/chat_message_created/notify_of_new_message.liquid` — debounces push notifications: waits 1 minute, then checks whether the message is still the last one in its conversation. If it was superseded by a newer message, it's a no-op (the newer message's own delayed check will fire instead). Otherwise it publishes `message_notification_to_send` (`message_id`, `app_host`), validated by `lib/events/message_notification_to_send.liquid`. + +`message_notification_to_send` has one consumer: + +- `lib/consumers/message_notification_to_send/send_push_notification.liquid` — sends a Web Push notification (via `pos-module-push-notifications`, a required dependency) to every other participant who hasn't already read the message (checked against `conversation.participant_read_ids`). Because it only fires 10 minutes after the last message in a burst, a rapid back-and-forth produces a single push, not one per message. ## Testing diff --git a/pos-module-chat/modules/chat/public/graphql/messages/search.graphql b/pos-module-chat/modules/chat/public/graphql/messages/search.graphql index f0053df..9ba4b7d 100644 --- a/pos-module-chat/modules/chat/public/graphql/messages/search.graphql +++ b/pos-module-chat/modules/chat/public/graphql/messages/search.graphql @@ -40,6 +40,7 @@ query search( conversation_id: property(name: "conversation_id") conversation: related_record(table: "modules/chat/conversation", join_on_property: "conversation_id", foreign_property: "id") { participant_ids: property_array(name: "participant_ids") + participant_read_ids: property_array(name: "participant_read_ids") } } } diff --git a/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/broadcast_new_message.liquid b/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/broadcast_new_message.liquid index 13d6b14..042bae2 100644 --- a/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/broadcast_new_message.liquid +++ b/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/broadcast_new_message.liquid @@ -10,10 +10,10 @@ views/partials/channels/notifications/subscribed.liquid (a user can only subscribe to their own room), so broadcasting here cannot leak to other users. - Unlike notify_of_new_message (the debounced email path), this delivers every - message immediately, hence the un-delayed background job with retries - (max_attempts) so a transient broadcast failure does not silently drop a - notification. + Unlike notify_of_new_message (the debounced push-notification path), this + delivers every message immediately, hence the un-delayed background job + with retries (max_attempts) so a transient broadcast failure does not + silently drop a notification. {% endcomment %} {% background message_id: event.message_id, max_attempts: 5 %} {% liquid diff --git a/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/notify_of_new_message.liquid b/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/notify_of_new_message.liquid index ab80f4b..9bf3538 100644 --- a/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/notify_of_new_message.liquid +++ b/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/notify_of_new_message.liquid @@ -1,4 +1,4 @@ -{% background delay: 10, message_id: event.message_id, max_attempts: 0, app_host: event.app_host %} +{% background delay: 1, message_id: event.message_id, max_attempts: 0, app_host: event.app_host %} {% liquid graphql g = 'modules/chat/messages/search', id: message_id assign message = g.messages.results.first diff --git a/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/send_push_notification.liquid b/pos-module-chat/modules/chat/public/lib/consumers/message_notification_to_send/send_push_notification.liquid similarity index 59% rename from pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/send_push_notification.liquid rename to pos-module-chat/modules/chat/public/lib/consumers/message_notification_to_send/send_push_notification.liquid index 1b8fffa..09d4885 100644 --- a/pos-module-chat/modules/chat/public/lib/consumers/chat_message_created/send_push_notification.liquid +++ b/pos-module-chat/modules/chat/public/lib/consumers/message_notification_to_send/send_push_notification.liquid @@ -1,12 +1,20 @@ {% comment %} - Consumer for the `chat_message_created` event. + Consumer for the `message_notification_to_send` event. + + This event is only published by notify_of_new_message once a message has + sat unsuperseded as the last message in its conversation for the debounce + window, so a burst of messages results in a single push notification (for + the final message) rather than one per message. Sends a Web Push notification (via pos-module-push-notifications) to every - other conversation participant immediately, one per message. Participant - ids on a conversation are profile ids, but push_notifications' broadcast - command is keyed by platformOS user id, so each participant profile is - resolved to its `user_id` before calling broadcast. Runs as a background - job with retries, matching broadcast_new_message.liquid. + other conversation participant who has not already read the message (i.e. + is not in conversation.participant_read_ids — see mark_read/mark_unread), + so a participant who was actively viewing the conversation during the + debounce window is not paged for something they've already seen. + Participant ids on a conversation are profile ids, but push_notifications' + broadcast command is keyed by platformOS user id, so each participant + profile is resolved to its `user_id` before calling broadcast. Runs as a + background job with retries, matching broadcast_new_message.liquid. {% endcomment %} {% background message_id: event.message_id, app_host: event.app_host, max_attempts: 5 %} {% liquid @@ -19,7 +27,7 @@ assign recipient_user_ids = [] for participant_id in message.conversation.participant_ids - unless participant_id == message.autor_id + unless participant_id == message.autor_id or message.conversation.participant_read_ids contains participant_id function recipient = 'modules/user/queries/profiles/find', id: participant_id, user_id: null, uuid: null, first_name: null, last_name: null if recipient.user_id assign recipient_user_ids << recipient.user_id From 2e35dfe96f92ae0f4445b3dbeb7a6e38056e9e49 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 22 Jul 2026 11:29:58 +0200 Subject: [PATCH 5/7] Do not display read messages --- pos-module-chat/CLAUDE.md | 3 ++- .../modules/chat/public/assets/js/pos-chat.js | 8 +++++++ .../channels/conversate/mark_read.liquid | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 pos-module-chat/modules/chat/public/views/partials/channels/conversate/mark_read.liquid diff --git a/pos-module-chat/CLAUDE.md b/pos-module-chat/CLAUDE.md index afdaca8..4aa536c 100644 --- a/pos-module-chat/CLAUDE.md +++ b/pos-module-chat/CLAUDE.md @@ -34,8 +34,9 @@ Action Cable channel actions are Liquid partials at `views/partials/channels/