Skip to main content

Per-thread "fake users" (participant personas)

REQUIREMENTS

To be able to implement this guide, you need to learn how to insert PHP snippets to your website.

You can find guide here: WP Beginner

Sometimes the WordPress user who actually receives a chat is not the identity the other side should see. Examples:

  • A buyer messages an "Agent CPT" that isn't linked to a real WP user — the chat is routed to a generic fallback account, but the buyer should still see the agent's name and profile photo.
  • A WooCommerce vendor's store-front persona should be shown to customers instead of the WP user behind the store.
  • A single fallback account routes inquiries for several different agent CPTs at once — each thread needs to show its own persona, all in the same conversation list.

Better Messages exposes a per-thread participant override map for exactly this case. Same WP user, different identity in different threads — at the same time.

What it looks like

The buyer's view of two unrelated threads that are both routed to the same underlying WordPress account. Each thread shows its own persona — name, profile photo and clickable profile URL — both in the sidebar and in the message sender chips. The Houzez integration is used here as an example, but the pattern is reusable from any addon.

Buyer-side view: two different agent personas (Mike Moore and Dave Harris) on threads that both route to the same fallback WP user, plus persona attribution on every reply

The agent side (the WordPress user the chats are actually routed to) sees the buyer's real identity in the conversation header, sees their own WordPress profile in their bottom-left widget (no leak into self-view), and gets a yellow "Chatting as Mike Moore" banner so they know which persona they are representing for this conversation:

Agent-side view: 'Chatting as Mike Moore' banner above the property card; bottom-left widget keeps the receiver's own WP identity

How it works

Add an optional participantOverrides map to the thread payload via the better_messages_rest_thread_item filter:

$thread_item['participantOverrides'] = array(
(string) $user_id => array(
'name' => 'Display Name', // optional
'avatar' => 'https://…/photo.png', // optional
'url' => 'https://…/profile/', // optional
),
);

Keys are stringified user IDs (positive WP IDs and negative guest IDs both work). All fields are optional — omit any field and the participant's real record fills the gap.

The map travels with the thread payload across every transport — REST, AJAX polling and WebSocket relays all build their payloads through the same filter, so the override flows everywhere automatically.

On the frontend, every surface that renders a participant inside a thread (conversation header, sidebar item, message sender chip, reactions modal, mini-chat-head widget, call screen, info panel, toasts, etc.) merges the override on top of the real user record at render time. Surfaces that step outside the thread context — the current-user widget, mention picker, user search, "Add participant" modal, user profile popup — deliberately keep using the real record, so the override never leaks into self-view or non-thread contexts.

Recipe

<?php
add_filter( 'better_messages_rest_thread_item', 'my_addon_persona_overrides', 10, 5 );

function my_addon_persona_overrides( $thread_item, $thread_id, $thread_type, $include_personal, $user_id ) {
if ( $thread_type !== 'thread' ) {
return $thread_item;
}

// 1) Decide which participant in this thread should wear a persona.
// Resolve it from your own data (CPT meta, vendor settings, etc.).
$persona = my_addon_resolve_persona_for_thread( $thread_id );
if ( ! $persona ) {
return $thread_item;
}

// 2) Only override participants who are actually in this thread.
$participants = isset( $thread_item['participants'] )
? array_map( 'intval', $thread_item['participants'] )
: array();

if ( ! in_array( $persona['user_id'], $participants, true ) ) {
return $thread_item;
}

// 3) Emit the override.
if ( ! isset( $thread_item['participantOverrides'] ) || ! is_array( $thread_item['participantOverrides'] ) ) {
$thread_item['participantOverrides'] = array();
}

$thread_item['participantOverrides'][ (string) $persona['user_id'] ] = array(
'name' => $persona['name'],
'avatar' => $persona['avatar'],
'url' => $persona['url'],
);

return $thread_item;
}

Three principles

1. Snapshot the persona on first resolve

Resolve once from your data sources, then cache the result in thread meta so subsequent renders are stable even if the upstream data drifts later:

$cached = Better_Messages()->functions->get_thread_meta( $thread_id, 'my_persona_user_id' );

if ( $cached ) {
// use cached values
} else {
// resolve fresh
Better_Messages()->functions->update_thread_meta( $thread_id, 'my_persona_user_id', $resolved_user_id );
// …same for any other persona fields you need (CPT post id, type, etc.)
}

This protects your buyers and agents from a confusing experience if a CPT title gets edited, a vendor changes their store name, or the linked WP account changes after the conversation already started.

2. Only override participants who are in the thread

The participants array on the thread item is your source of truth. Don't add participantOverrides entries for user IDs that aren't part of the thread — they would never be rendered anyway, but you'd be putting noise into every payload (and in WebSocket pushes that fan out to many recipients).

3. Don't double-override

If you also want the persona to apply to that user in non-thread contexts (user search, mention picker, "@" autocomplete) — meaning the user always appears as the persona, everywhere — additionally hook better_messages_rest_user_item and rewrite name / avatar / url there. The per-thread override and the global one compose cleanly.

Do not implement ad-hoc "global override based on last thread we saw" tricks. That's exactly the kind of fragile global-state pattern the per-thread mechanism replaces, and it breaks the moment the same user appears in two threads with different personas.

Optional: a "Chatting as X" banner

If a thread is routed to a generic fallback account, you'll want to tell the receiver that the other side sees a different identity. Append HTML to $thread_item['threadInfo'] only when the viewer is on the persona side and is not the persona's primary linked user:

if ( $viewer_is_on_persona_side && $viewer_id !== $persona_primary_user_id ) {
$banner = '<div class="my-addon-persona-banner">'
. '<img src="' . esc_url( $persona['avatar'] ) . '" alt="" /> '
. sprintf( esc_html__( 'Chatting as %s', 'my-addon' ), '<strong>' . esc_html( $persona['name'] ) . '</strong>' )
. '</div>';

$thread_item['threadInfo'] = ( $thread_item['threadInfo'] ?? '' ) . $banner;
}

threadInfo is rendered above the conversation pane and is per-viewer — the filter receives the viewer's $user_id, so a banner you set there is correctly scoped to the receiver who needs it.

Reference implementations

Two integrations inside the plugin use this exact pattern and are the reference implementations:

  • addons/houzez.php — Houzez agent CPTs, including the case where the agent CPT has no linked WordPress user and the chat is silently routed to the property's post author.
  • addons/realhomes.php — RealHomes agent and agency CPTs, same routing behavior, plus the agency case (an agency CPT routed to its post author, with the agency name and logo shown to the buyer).

Both share the same method names so the pattern is easy to copy:

  • thread_item() — the filter callback
  • resolve_thread_persona() — snapshots persona post id and type to thread meta
  • resolve_thread_routed_user() — snapshots the actual routed user id
  • apply_realtor_to_user_item() (and resolve_realtor_avatar_url() in Houzez) — build the override values
  • persona_banner_html() — the "Chatting as X" banner

Testing checklist

When adding a persona to a new integration:

  1. Open a thread that should have the persona — header, sidebar item and message sender chip all show the persona name, avatar, and clickable profile URL.
  2. The routed receiver opens their own dashboard — their bottom-left profile widget shows their real WordPress identity (the override does not leak into self-view).
  3. Open a second thread routed to the same WP user but with a different persona — both threads in the sidebar must show their own persona side-by-side. No "last-write-wins" effects.
  4. Send a message from each side over WebSocket — the inbound payload carries thread.participantOverrides and the receiving UI updates without a refresh.
  5. Trash the persona's source post (CPT, vendor, etc.) — the override is dropped silently and the thread falls back to the routed user's real identity.
  6. Reload the page — the override survives because it's snapshotted in thread meta and re-emitted on every fetch.