Joinery Messenger

The member-facing messages app: open /profile/messenger, see your conversations, and talk to another member or a small group the way you would on any modern chat app — instant-feeling delivery, read receipts, typing indicators, replies, emoji reactions, photos and files, group names and membership.

Two people whose accounts live on different Joinery instances can also message each other. The message travels over Joinery Direct — the signed instance-to-instance channel — not through any third party.

Conversations carry a protection level from the platform ladder: Standard, Private (sealed at rest) and Guarded (Private with the doors guarded).

Where things are

PathWhat
views/profile/index.phpThe app, at /profile/messenger
logic/messenger_*_logic.phpThe /api/v1 actions the app calls
includes/Messenger.phpThe shared middle: gates, settings, and the one description of a conversation and a message
includes/MessengerTyping.phpTyping state, in APCu, never in the database
includes/MessengerUploads.phpPhotos and files on their way in
includes/MessengerAttachmentGate.phpWho may open an attachment
includes/MessengerFederation.phpThe sending end of cross-instance chat
includes/ChatDirectHandler.phpThe receiving end — the chat kind on Joinery Direct
includes/bootstrap.phpSealed Vault hooks: the attachment decrypt hook and the key-rotation re-seal
tasks/DrainChatOutbox.phpRetries cross-instance messages that have not landed
assets/css/messenger.cssThis app's layout (the bubbles come from the shared kit)
assets/js/messenger.jsOne renderer for the first paint and every later change
The data layer is core, not here. Conversations, participants, messages, reactions, attachments, key grants and remote peers all live in data/ at the platform root, because the iOS member surface and other plugins build on the same rows. The developer guide for all of it is docs/social_features.md § Messaging / Conversations.

Turning it on

Activating the plugin is the whole installation. It seeds its settings, adds Messages to the member menu, and points the header envelope icon at the app; the older /profile/conversations pages hand off to it. Deactivating reverses all of that and touches no message.

Cross-instance chat additionally needs the mailbox plugin (it supplies the member's address, the contact list that authorizes an incoming message, and the Direct endpoint) and a Direct signing identity for the sending domain. Without those the app works normally and does not offer cross-site chat — and a superadmin opening the picker sees one line saying cross-site chat isn't set up, linking to the Joinery Direct switch on General Settings, so absence never reads as breakage.

Starting a conversation — the one search box

The New-message dialog has a single search field that resolves everything a member might mean:

  • Members match by name (never by email — partials cannot enumerate who holds an address here).
  • The member's own mailbox contacts match by name or address and are shown with the address. They appear whenever the site can chat cross-site; a member who lacks a sendable mailbox is told exactly that at pick time.
  • A typed full address is always usable as a "Use address" row.
Picking a contact or address resolves its state inline, before anything is committed: an address on this site's own mail domains opens a plain local conversation with the member behind it (exact address only, and only when one live member holds the mailbox — a shared mailbox stays email-only); a domain that publishes the chat capability becomes a remote chip; anything else says it cannot be reached by chat and offers Send an email instead plus Check again, which re-resolves past the reachability cache (rate-limited) so a network blip's cached "no" cannot answer for the retry. Both halves of the DNS handshake are verified before anything sends: if this site's own records aren't published yet, the pick says exactly that — our state, not theirs — and JoineryDirect::send() refuses before the wire regardless of surface.

Cross-site conversations are one-to-one and Standard: a remote chip freezes further picks and pins the level, with the reason shown, at the moment of the click. Enter in the search box picks the first result; it never closes the dialog. Opening a chat never creates a contact — a contact is an inbound-permission grant and only a deliberate add mints one.

Every cross-site "no" reads the same on the wire (a refusal, a missing capability record and a too-old instance are indistinguishable by design), so reachability reports whether, never why.

Settings

SettingDefaultWhat it does
messenger_activeonThe app and its actions
messenger_default_protection_levelstandardWhat a new conversation starts at
messenger_max_group_size32People per group
messenger_max_attachment_mb25Per file
messenger_poll_thread_seconds3How often an open conversation asks for new messages
messenger_poll_list_seconds12How often the list refreshes with nothing open
The platform's own messaging_active switch is honoured too: with member messaging off, the app is off.

Two things worth knowing before changing it

There is no realtime service, and that is deliberate. The app polls. A held text/event-stream response pins a php-fpm worker per open tab, and the fleet runs small worker pools — a handful of members with the app open in two tabs each would exhaust one. Each poll is a single indexed query keyed on a message id and releases the worker immediately. The upgrade path is kept open at no cost: add_message() emits NOTIFY message_events, so a future service LISTENs on it and pushes over its own transport while this endpoint becomes the fallback.

A protected conversation has one key with many holders. Every other sealed model on the platform seals to a single owner; this one wraps one conversation key separately to each participant, and the server reads a message only while somebody who holds a wrapping is present. Anything touching msg_body on a sealed row has to go through the model — a raw SELECT gets ciphertext, which is exactly what it should get.

Tests

php tests/run.php db --filter=messenger_core        # groups, cursor, reactions, receipts, legacy actions
php tests/run.php db --filter=messenger_sealed      # protection: grants, raise, locked reads, rotation
php tests/run.php db --filter=messenger_federation  # the chat kind: ingest, dedup, control payloads