The Member Chat module adds room-based discussion to BricksMembers with a Bricks-first rendering model, AJAX polling, optional WebSocket transport, unread tracking, and Groups-aware primary rooms. This document covers the internal service layer, Bricks integration points, and the runtime contracts the implementation relies on.
Important: Member Chat is currently a service-driven internal module. It does not ship a broad public helper-function surface yet, so internal BRM code should prefer the PSR-4 services below instead of inventing raw SQL or parallel wrappers.
Checking Module Status
use BaselMedia\BricksMembers\Core\ModuleRegistry;
if ( ModuleRegistry::is_active( 'member_chat' ) ) {
// Member Chat is enabled.
}
Storage Model
Member Chat creates three module-scoped tables:
{prefix}brm_chat_rooms— room definitions, policy, room kind, linked post, last message pointers{prefix}brm_chat_messages— room messages, edit/delete state, author, timestamps{prefix}brm_chat_members— read-state/subscription rows, last-read pointers, joined-at metadata
Schema creation is owned by CoreSchemaService::ensure_chat_schema(). The module-level creation marker is brm_chat_tables_created.
Core Services
ChatRoomService
use BaselMedia\BricksMembers\Modules\MemberChat\ChatRoomService;
$rooms = ChatRoomService::get_instance();
Primary responsibilities:
- module settings (
brm_chat_settings) - room CRUD
- room-source resolution for Bricks and AJAX
- read/write/manage/topic-create policy checks
- accessible room listing with unread metadata
- primary group room provisioning
- slug generation with bounded uniqueness checks
Common methods:
$settings = $rooms->get_settings();
$room_id = $rooms->create_room( array(
'name' => 'Community Lounge',
'scope' => 'global',
'room_kind' => 'standard',
'linked_post_id' => 0,
'level_ids_read' => array( 1, 2 ),
'level_ids_write' => array( 2 ),
'created_by' => get_current_user_id(),
) );
$room = $rooms->get_room( (int) $room_id );
$rooms->update_room( (int) $room_id, array( 'description' => 'General member chat' ) );
$rooms->archive_room( (int) $room_id );
$active_room_id = $rooms->resolve_active_room_id_for_user( get_current_user_id(), get_the_ID() );
$resolved_room_id = $rooms->resolve_room_id_for_source( get_current_user_id(), 'group', 0, get_the_ID() );
$can_read = $rooms->user_can_read( get_current_user_id(), $active_room_id );
$can_write = $rooms->user_can_write( get_current_user_id(), $active_room_id );
$can_create_topic = $rooms->user_can_create_topic( get_current_user_id(), $group_id );
Current room source options are:
inherit— use the current request-scoped chat contextgroup— resolve the user’s active group room for the current postspecific— use one explicit room ID if readable
ChatMessageService
use BaselMedia\BricksMembers\Modules\MemberChat\ChatMessageService;
$messages = ChatMessageService::get_instance();
Primary responsibilities:
- create messages
- fetch latest windows or deltas
- edit and soft-delete
- room last-message updates
- message-level permission checks
$message = $messages->create_message(
$room_id,
get_current_user_id(),
'Welcome to the room.'
);
$latest_id = $messages->get_latest_message_id( $room_id );
$latest_messages = $messages->get_messages_for_room( $room_id, 30 );
$older_messages = $messages->get_messages_before_id( $room_id, $latest_id, 30 );
$newer_messages = $messages->get_messages_since_id( $room_id, 150, 50 );
$edited = $messages->edit_message( $message_id, get_current_user_id(), 'Updated text' );
$deleted = $messages->delete_message( $message_id, get_current_user_id() );
Version 1 message content is plain text. Content is sanitized on write and rendered escaped on output.
ChatMemberService
use BaselMedia\BricksMembers\Modules\MemberChat\ChatMemberService;
$members = ChatMemberService::get_instance();
Primary responsibilities:
- lazy member/read-state row creation
- mark-read updates
- unread counts
- group room membership sync
- room member counts for non-group rooms
$state = $members->ensure_member_state( $room_id, get_current_user_id() );
$members->mark_read( $room_id, get_current_user_id(), $last_message_id );
$unread = $members->get_unread_count( $room_id, get_current_user_id() );
// Keep a group room aligned with active BRM group membership.
$members->sync_group_room_members( $group_id, $room_id );
Note that brm_chat_members is not the source of truth for global-room visibility. It is a read-state/subscription projection. Global access is still policy-based.
ChatViewRefreshService
use BaselMedia\BricksMembers\Modules\MemberChat\ChatViewRefreshService;
ChatViewRefreshService::ajax_refresh_chat_element();
This service owns the server-rendered refresh contract for chat feeds. It re-renders the target Bricks element for the current page/template context, temporarily sets request-scoped chat overrides through ChatContext, and returns the final HTML fragment instead of raw message markup built in JavaScript. Template resolution is limited to a single matching template (posts_per_page => 1) to avoid broad scans.
Request-Scoped Chat Context
ChatContext stores the current room, current message, SSR override room ID, and temporary view-state values for the current request. This is what makes nested Bricks query loops, conditions, and dynamic tags work inside a chat feed.
The context uses a stack-based push/pop pattern so that each query loop iteration cleanly replaces the previous context without leaking:
use BaselMedia\BricksMembers\Modules\MemberChat\ChatContext;
// Push context for a room or message (typically done by query loop integration).
ChatContext::push_room( $room );
ChatContext::push_message( $message );
// Read current context (used by dynamic tags, conditions, elements).
$current_room = ChatContext::get_current_room();
$current_message = ChatContext::get_current_message();
// Pop context (done by query loop integration after each iteration and after the final loop).
ChatContext::pop_room();
ChatContext::pop_message();
The BricksQueryIntegration loop_object callback pops the previous iteration’s context before pushing the new one (safe no-op on first iteration). The bricks/query/after_loop hook pops the final iteration’s context for cleanup.
Shared Element Trait
The three chat elements share common resolution logic through ChatElementTrait:
namespace BaselMedia\BricksMembers\Elements;
trait ChatElementTrait {
private function resolve_specific_room_id( array $settings ): int { /* ... */ }
private function resolve_context_post_id(): int { /* ... */ }
private function resolve_content_template_id(): int { /* ... */ }
}
Each element file loads the trait via require_once __DIR__ . '/ChatElementTrait.php' because Bricks elements are not autoloaded through PSR-4 (they are registered via file path).
Bricks Integration
Elements
All three elements are nestable ($nestable = true), meaning designers place child elements inside them in the Bricks structure panel rather than using fixed HTML templates.
BRM_Element_Chat_Feed(brm-chat-feed) — room resolution, server-rendered message output via nestable children, polling metadata. Children are rendered once per message usingFrontend::render_children(). Supports two layout modes for message rows (see “Message Layout Mode” below).BRM_Element_Chat_Room_List(brm-chat-room-list) — loops over accessible rooms for the current user. Each child layout is repeated per room. Room items emitdata-brm-chat-room-switch="1"anddata-room-idfor JavaScript-driven room switching. Items includerole="button"andtabindex="0"for keyboard accessibility.BRM_Element_Chat_Input(brm-chat-input) — message composer form. Renders a textarea inside a<form>. All buttons (send, topic toggle, topic submit, topic cancel) are user-placed Bricks elements connected via data attributes or the injected Chat Action control. Shows a read-only notice whenuser_can_write()returns false. The textarea slot is injected into a child Div marked withdata-brm-chat-textarea-slot="1".
Elements are connected via a shared Chat Group Key (data-chat-group attribute, defaults to "default"). When a room is clicked in the room list, the JavaScript finds the feed and input with the same group key and switches them to the selected room via AJAX refresh.
Message Layout Mode
The Chat Feed element exposes a messageLayoutMode control with two values:
single(default) — one child layout for all messages. Each message row wrapper gets abrm-chat-feed__message-row--mineCSS class when the message belongs to the current user. Styling differences are handled via CSS.separate— two independent child Div layouts. Each Div is assigned a Chat Layout Role (brmChatLayoutRolecontrol on the Bricks Div element) of eithermineorothers. During rendering, the feed temporarily swaps$this->element['children']to include only the matching layout Div for each message, then callsFrontend::render_children().
The layout mode is emitted as data-layout-mode on the root element. The resolve_layout_children() private method inspects \Bricks\Database::$page_data['elements'] to find direct child Divs with the brmChatLayoutRole setting and returns a map of role to child element ID.
Injected Controls on Core Bricks Elements
The chat module injects controls into core Bricks elements via chat_register_injected_controls(), registered on the init action:
Button Element
Filter: bricks/elements/button/controls
brmChatAction(select) — None, Send Message, Toggle Topic Creator, Submit Topic, Cancel TopicbrmChatGroupKey(text) — Chat Group Key, shown only when action is “Send Message”
Div Element
Filter: bricks/elements/div/controls
brmChatLayoutRole(select) — None, My Messages Layout (mine), Others Messages Layout (others)
Render Attribute Injection
Filter: bricks/element/render_attributes
Based on the control values, the following data attributes are injected on the element root:
- Button with
brmChatAction = send:data-brm-chat-submit="{groupKey}" - Button with
brmChatAction = topic_toggle:data-brm-chat-topic-toggle="1" - Button with
brmChatAction = topic_submit:data-brm-chat-topic-submit="1" - Button with
brmChatAction = topic_cancel:data-brm-chat-topic-cancel="1" - Div with
brmChatLayoutRole = mine|others:data-brm-chat-layout="{role}"
Custom Query Types
brm_chat_messages— current room messagesbrm_chat_rooms— accessible rooms for current user
Both query types are registered through BricksQueryIntegration. The query loop integration pushes/pops ChatContext per iteration and registers a cleanup handler via bricks/query/after_loop.
Dynamic Tags (24 tags)
The tag family is {brm_chat:*}, registered under the BRM: Chat group. All tag values are output-escaped with context-appropriate functions (esc_html() for text, esc_url() for URLs, sanitize_email() for emails).
Room tags (resolve inside any context with an active room):
room_id,room_name,room_descriptionroom_scope,room_kindroom_unread_count,room_member_count,room_can_write
Message tags (resolve inside chat feed message rows):
message_id,message_contentmessage_author_id,message_author_name,message_author_loginmessage_author_first_name,message_author_last_name,message_author_nickname,message_author_emailmessage_created_at,message_edited_at,message_statusmessage_is_mine,message_is_edited,message_is_deleted,message_can_manage
Bricks User Tag Override
When a ChatContext message is active, the bricks/dynamic_data/user_value filter intercepts built-in Bricks {wp_user_*} tags and re-resolves them for the message author instead of the logged-in user. This means tags like , , and automatically point to the correct person inside chat feed message rows without any extra configuration.
The override is registered in InternalUtilityHookBootstrap and implemented in ChatDynamicTags::override_bricks_user_tag_for_message().
Conditions (8 conditions)
All chat conditions are registered under the BRM: Chat group via BricksConditionsRegistrar.
Room-level conditions:
brm_chat_has_room— whether a room context exists (compare: is, value: yes/no)brm_chat_can_write— whether the current user can write in the active room (compare: is, value: yes/no)brm_chat_room_scope— compare against room scope value (compare: is/is not, value: global/group)brm_chat_unread_count— compare unread count against a threshold (compare: >=, <=, ==, >, <)
Message-level conditions (resolve inside chat feed message rows via ChatContext::get_current_message()):
brm_chat_message_is_mine— whether the message belongs to the current userbrm_chat_message_is_edited— whether the message has been edited (and is not deleted)brm_chat_message_is_deleted— whether the message has been soft-deletedbrm_chat_message_can_manage— whether the current user can moderate the message
Room-level conditions fall back to resolving the active room for the current user/post context when no request-scoped room exists.
AJAX Endpoints
Member Chat endpoints are registered in src/Ajax/ChatActions.php. All endpoints use AjaxHandlers::verify_ajax_request() and the frontend nonce context. Mutation endpoints include defense-in-depth access checks at the AJAX layer in addition to service-layer validation.
brm_chat_feed_freshness— lightweight polling check for latest message ID and unread countbrm_chat_fetch_messages— alias for feed_freshness used by the pollerbrm_chat_refresh_feed— SSR refresh for the chat feed elementbrm_chat_send_message— create a new message (validatesuser_can_write()at AJAX layer)brm_chat_mark_read— update last-read statebrm_chat_edit_message— edit an allowed message (validates message ID and user ID)brm_chat_delete_message— soft-delete an allowed message (validates message ID and user ID)brm_chat_room_list— return accessible rooms for current userbrm_chat_room_info— return one room payloadbrm_chat_create_topic— create a topic room when allowed
Frontend JavaScript
The chat frontend (assets/js/chat.js) initializes by scanning for three data attributes:
[data-brm-chat-feed="1"]— feed containers[data-brm-chat-input="1"]— input forms[data-brm-chat-room-list="1"]— room list containers
Elements with the same data-chat-group value are linked.
Submit Button Discovery
The Chat Input element does not render built-in buttons. The JavaScript discovers send buttons through findSubmitButton(form) which searches in this order:
[data-brm-chat-submit]inside the form[data-brm-chat-submit="{groupKey}"]anywhere in the document (matching the form’sdata-chat-group)button[type="submit"]inside the form (legacy fallback)
All [data-brm-chat-submit] buttons are initialized via setupSubmitButtons(scope) during init. Each button gets a click handler that calls performSubmit(form, btn) and a data-brm-chat-submit-initialized flag to prevent duplicate binding.
Enter Key Submission
Each textarea in a chat input form gets a keydown listener. Pressing Enter (without Shift) calls performSubmit(form, findSubmitButton(form)). Shift+Enter inserts a newline. The performSubmit() function handles a null button gracefully, so Enter works even if no send button exists on the page.
Topic Creator Buttons
[data-brm-chat-topic-toggle="1"] buttons toggle the visibility of the [data-brm-chat-topic-creator="1"] panel. [data-brm-chat-topic-submit="1"] buttons dispatch a submit event on the [data-brm-chat-topic-form="1"] form inside the same wrapper. [data-brm-chat-topic-cancel="1"] buttons hide the panel and reset the form.
Room Switching
[data-brm-chat-room-switch="1"] elements trigger room switches on click or keyboard activation (Enter/Space). The handler extracts data-room-id, finds the linked feed via the chat group, updates the active CSS class on room list items, and calls refreshFeed(feed, roomId).
Transport
Two transport modes are supported:
- Polling (default) — adaptive interval polling via
ChatPoller. Base interval comes from the feed’sdata-poll-intervalattribute (minimum 5000ms). The poller applies a 1.5x multiplier after 3 idle cycles (capped at 15000ms) and resets on new messages. - WebSocket (optional) — real-time via
ChatWebSocketsupporting Pusher and Ably providers. Subscribes toprivate-brm-room-{roomId}channels. Falls back to polling on connection failure.
Localized Script Data
The brmChat object is localized via wp_localize_script() by ChatAssetSupport and contains:
ajaxUrl— WordPress admin-ajax.php URLnonce— frontend security noncepageId— current post/page IDtransport—"websocket"or"polling"wsConfig— WebSocket provider configurationstrings— all user-facing i18n strings (no hardcoded English in JS)
WebSocket Module
The WebSocket subsystem lives in src/Modules/MemberChat/WebSocket/ with a driver-based architecture:
WebSocketModule— main orchestrator, checks if WebSocket is active, provides JS configWebSocketDriver— base driver interfacePusherDriver— Pusher implementationAblyDriver— Ably implementationPusherProtocolDriver— Pusher HTTP protocol driver (fallback)
Provider credentials are stored in the module settings and configured on the Member Chat admin page directly below the Realtime Provider dropdown.
Groups Lifecycle Integration
MemberChatSystem binds Member Chat to the Groups event model:
brm_event_group_created— create or resolve the primary group room and backfill active membersbrm_event_group_member_added— ensure read-state for the new member and re-sync membershipbrm_event_group_member_removed— remove the user from all rooms for that groupbrm_event_group_archived— archive all group rooms
The primary group room is always scope = group plus room_kind = primary_group.
Events
The event dispatcher emits chat-specific typed events as part of the normal BRM event pipeline:
brm_event_chat_room_createdbrm_event_chat_message_sentbrm_event_chat_message_editedbrm_event_chat_message_deleted
Each event carries the usual typed Event payload plus room/message identifiers in $event->get_context().
Admin UI
The admin page lives at admin.php?page=brm_member_chat and uses the same BRM admin shell and shared component classes as the other PSR-4 admin pages. The module toggle itself now lives on BricksMembers → Modules.
Security and Runtime Notes
- All request reads use the
Securityutility instead of raw superglobals. - Message content is sanitized to plain text in version 1.
- All dynamic tag return values are output-escaped with context-appropriate functions (
esc_html(),esc_url(),sanitize_email()). - AJAX mutation handlers include defense-in-depth access checks (e.g.
user_can_write()on send) in addition to service-layer validation. - AJAX handlers validate input IDs are positive before processing (early guard against zero/negative values).
- Global access is policy-based, not pre-provisioned through room-member rows.
- Feed updates use server-rendered Bricks HTML instead of client-side canonical message templates.
- Rate limiting includes chat-specific profiles for feed polling, refresh, send, edit, delete, and topic creation.
- Slug generation uses a bounded loop (max 100 attempts) with random suffix fallback.
- User-facing JavaScript strings come from PHP-localized script data; no hardcoded English in JS files.
- The
resolve_layout_children()method reads from\Bricks\Database::$page_data['elements']which is already loaded in memory; it does not trigger additional database queries.