Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions pos-module-chat/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -33,7 +33,9 @@ function conversations = 'modules/chat/queries/conversations/search_by_participa
Action Cable channel actions are Liquid partials at `views/partials/channels/<channel>/<action>.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`.

Expand All @@ -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-<profile_id>` 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

Expand Down
10 changes: 10 additions & 0 deletions pos-module-chat/modules/chat/public/assets/js/pos-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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' %}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
</ul>
{% if conversations.total_entries == 0 %}
<div class="pos-chat-conversations-blank-mobile">
{% render 'modules/common-styling/icon', icon: 'messageBubblesDouble' %}
{% render 'modules/common-styling/icon', icon: 'messageBubblesDouble', class: null %}
<h2 class="pos-heading-2">{{ 'modules/chat/blank.title' | t }}</h2>
{{ 'modules/chat/blank.description.mobile' | t }}
</div>
Expand All @@ -88,7 +88,7 @@
<header class="pos-chat-conversation-header">
<a href="/inbox" class="pos-chat-conversation-header-back pos-button">
<span class="pos-label">Show conversations list</span>
{% render 'modules/common-styling/icon', icon: 'dashLeft' %}
{% render 'modules/common-styling/icon', icon: 'dashLeft', class: null %}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any idea why the linter requires this? I've marked the class as optional in common-styling so I wouldn't have to do this, but still...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me check, must be a bug

</a>
{% liquid
assign participants = current_conversation.participants
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<div class="pos-chat-search">
<div class="pos-chat-search-input-container">
{% render 'modules/common-styling/icon', icon: 'search' %}
{% render 'modules/common-styling/icon', icon: 'search', class: null %}
<input type="text" class="pos-form-input pos-chat-search-input" placeholder="{{ 'modules/chat/search.placeholder' | t }}">
<button type="button" class="pos-chat-search-clear">
{% render 'modules/common-styling/icon', icon: 'x' %}
{% render 'modules/common-styling/icon', icon: 'x', class: null %}
</button>
</div>
</div>
Expand Down
5 changes: 3 additions & 2 deletions pos-module-chat/modules/chat/template-values.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
5 changes: 3 additions & 2 deletions pos-module-chat/pos-module.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"machine_name": "push_notifications",
"version": "1.0.0",
"version": "1.0.1",
"name": "Push Notifications",
"dependencies": {
"core": "^2.1.9",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %}
<!DOCTYPE html>
<html lang="{{ context.language }}">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Push Notifications — Test</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; color: #374151; }
h1 { font-size: 1.25rem; }
.notice { padding: .75rem 1rem; border-radius: .25rem; margin-bottom: 1rem; }
.notice-info { background: #eff6ff; border: 1px solid #bfdbfe; }
.notice-success { background: #ecfdf5; border: 1px solid #a7f3d0; }
label { display: block; margin-top: .75rem; font-weight: 600; }
input[type="text"] { width: 100%; padding: .5rem; margin-top: .25rem; border: 1px solid #d1d5db; border-radius: .25rem; }
button { margin-top: 1rem; padding: .5rem 1rem; border: 0; border-radius: .25rem; background: #111827; color: #fff; cursor: pointer; }
</style>
</head>
<body>
<h1>Push Notifications — Test</h1>

{% if context.current_user == blank %}
<p class="notice notice-info">Log in to send a test push notification.</p>
{% else %}
{% if queued != blank %}
<p class="notice notice-success">Queued for {{ queued }} subscription(s).</p>
{% endif %}

{% if subs.total_entries == 0 %}
<p class="notice notice-info">You have no active subscriptions yet — subscribe first, then come back here.</p>
{% else %}
<p>You have {{ subs.total_entries }} active subscription(s).</p>
{% endif %}

<form method="post" action="/_push_notifications">
<input type="hidden" name="authenticity_token" value="{{ context.authenticity_token }}">

<label for="pos-push-test-title">Title</label>
<input type="text" id="pos-push-test-title" name="title" value="Test notification">

<label for="pos-push-test-body">Body</label>
<input type="text" id="pos-push-test-body" name="body" value="This is a test push notification.">

<label for="pos-push-test-url">URL</label>
<input type="text" id="pos-push-test-url" name="url" value="/">

<button type="submit">Send test notification</button>
</form>
{% endif %}
</body>
</html>
{% endif %}
Original file line number Diff line number Diff line change
@@ -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
%}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pos-module-push-notifications/pos-module.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading