diff --git a/pos-module-chat/CLAUDE.md b/pos-module-chat/CLAUDE.md index 1ff6f4f..aadc432 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`. @@ -33,7 +33,9 @@ function conversations = 'modules/chat/queries/conversations/search_by_participa Action Cable channel actions are Liquid partials at `views/partials/channels//.liquid`. The channel is `conversate`: - `channels/conversate/subscribed.liquid` — authorization gate: echoes `'true'`/`'false'` depending on whether `current_profile` is a participant of `room_id` (the conversation id). Returning `false` makes Action Cable reject the subscription. -- `channels/conversate/receive.liquid` — handles an incoming message: re-verifies participation, escapes the body with `raw_escape_string`, creates the message via the command, and marks the conversation unread for the recipient. **Sender-side persistence**: if the receiver is not a participant the message is skipped here and persisted on the sender's side instead (see the skip log). +- `channels/conversate/receive.liquid` — handles an incoming message: re-verifies participation, escapes the body with `raw_escape_string`, creates the message via the command, and marks the conversation unread for the recipient. **Sender-side persistence**: if the receiver is not a participant the message is skipped here and persisted on the sender's side instead (see the skip log). It also doubles as the mark-read ping: when the payload carries `mark_read: true` (sent by `pos-chat.js` whenever it renders a live message while the tab is visible), it marks the current participant read instead of creating a message, and echoes `"false"` to suppress the default rebroadcast-to-room. This is what the debounced push notification (see Events below) checks against. + + `receive` is the *only* action `WebNotificationsChannel` (the Ruby ActionCable channel backing this) dispatches to — `subscribed` is a lifecycle callback, not a `perform`-able action. A custom action name (e.g. a client calling `.perform('mark_read')`) is silently dropped server-side since no such method exists on the channel; that's why mark-read piggybacks on `receive` via a payload flag instead of being its own partial. Both handlers independently re-check participation — do not assume `subscribed` authorization carries into `receive`. @@ -59,7 +61,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`). 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 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/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/assets/js/pos-chat.js b/pos-module-chat/modules/chat/public/assets/js/pos-chat.js index de589d4..5fa692b 100644 --- a/pos-module-chat/modules/chat/public/assets/js/pos-chat.js +++ b/pos-module-chat/modules/chat/public/assets/js/pos-chat.js @@ -293,6 +293,16 @@ window.pos.modules.chat = function(userSettings = {}){ }) ); + // We're actively rendering this message on screen right now - tell the + // server so it doesn't count as unread and trigger a push notification. + // Skipped while the tab is hidden/backgrounded, since the message isn't + // actually being seen in that case. Uses .send (-> the `receive` action, + // the only one ActionCable dispatches to here), not .perform - a custom + // action name would be silently dropped server-side. + if(data.status === 'received' && document.visibilityState === 'visible'){ + module.channel.send({ mark_read: true }); + } + if(module.settings.debug){ if(data.status === 'received'){ pos.modules.debug(module.settings.debug, module.settings.id, 'Message received', data); 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/message_notification_to_send/send_push_notification.liquid b/pos-module-chat/modules/chat/public/lib/consumers/message_notification_to_send/send_push_notification.liquid new file mode 100644 index 0000000..3372f6c --- /dev/null +++ b/pos-module-chat/modules/chat/public/lib/consumers/message_notification_to_send/send_push_notification.liquid @@ -0,0 +1,52 @@ +{% comment %} + 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 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 + 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 + unless 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 + endif + endunless + 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/public/views/partials/channels/conversate/receive.liquid b/pos-module-chat/modules/chat/public/views/partials/channels/conversate/receive.liquid index 4762db6..ad69e99 100644 --- a/pos-module-chat/modules/chat/public/views/partials/channels/conversate/receive.liquid +++ b/pos-module-chat/modules/chat/public/views/partials/channels/conversate/receive.liquid @@ -20,18 +20,30 @@ function conversation = 'modules/chat/queries/conversations/find_by_participant', id: room_id, participant_id: current_profile.id, include_messages: null if conversation - assign message_safe = context.params.message | raw_escape_string - assign object = { "conversation_id": conversation.id, "autor_id": current_profile.id, "message": message_safe } - function message = 'modules/chat/commands/messages/create', object: object - if message.valid != true - log message, 'ERROR receive message' + if context.params.mark_read + # Sent by the client (see pos-chat.js) when it renders a message live while + # the tab is visible. This still goes through `receive` - it's the only + # action ActionCable's WebNotificationsChannel dispatches to (anything else, + # e.g. a custom `perform('mark_read')`, is silently dropped server-side since + # no such method exists on the channel). Echoing "false" suppresses the + # default rebroadcast-to-room the Ruby channel does for any other output, so + # other participants don't receive this ping as if it were a message. + function _res = 'modules/chat/commands/conversations/mark_read', conversation: conversation, participant_id: current_profile.id echo "false" else - function _res = 'modules/chat/commands/conversations/mark_unread', conversation: conversation, current_profile: current_profile + assign message_safe = context.params.message | raw_escape_string + assign object = { "conversation_id": conversation.id, "autor_id": current_profile.id, "message": message_safe } + function message = 'modules/chat/commands/messages/create', object: object + if message.valid != true + log message, 'ERROR receive message' + echo "false" + else + function _res = 'modules/chat/commands/conversations/mark_unread', conversation: conversation, current_profile: current_profile - # Recipients are notified of the new message by the chat_message_created consumer - # (lib/consumers/chat_message_created/broadcast_new_message), which runs in a - # retryable background job. messages/create publishes that event on persist. + # Recipients are notified of the new message by the chat_message_created consumer + # (lib/consumers/chat_message_created/broadcast_new_message), which runs in a + # retryable background job. messages/create publishes that event on persist. + endif endif else log "Skippind. Message will be persisted on sender side", 'channel_actions/conversate skip_message' 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 @@ diff --git a/pos-module-chat/modules/chat/template-values.json b/pos-module-chat/modules/chat/template-values.json index 9763102..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.2", + "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 3f141d0..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.2" + "version": "2.1.0" } \ No newline at end of file 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",