heimrichhannot / contao-simple-member-chat
Private one-to-one text conversations for Contao frontend members.
Package info
github.com/heimrichhannot/contao-simple-member-chat
Type:contao-bundle
pkg:composer/heimrichhannot/contao-simple-member-chat
Requires
- php: ^8.4
- contao/core-bundle: ^5.7
- contao/manager-plugin: ^2.13
- doctrine/dbal: ^3.10
- heimrichhannot/contao-encore-contracts: ^1.5
- symfony/rate-limiter: ^7.4
- symfony/uid: ^7.4
Requires (Dev)
- contao/contao-rector: dev-main
- contao/test-case: ^5.0
- heimrichhannot/contao-pwa-bundle: ^0.10.1
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^12.0
- rector/rector: ^2.0
- symplify/easy-coding-standard: ^12.0
Suggests
- heimrichhannot/contao-pwa-bundle: Enables optional push notifications for new chat messages.
- heimrichhannot/contao-ux-turbo-encore: Turbo is provided by the project: activate huh_ux_turbo_encore or huh_ux_turbo_encore_no_drive on the chat page.
Provides
None
Conflicts
None
Replaces
None
README
Private one-to-one plain-text conversations for Contao frontend members, with
contact search, unread counts, muted conversations, history loading and backend
moderation. Updates use Turbo Frame polling. Package:
heimrichhannot/contao-simple-member-chat (LGPL-3.0-or-later).
Requirements and installation
- PHP 8.4+, Contao 5.7, Symfony 7.4, Doctrine DBAL 3.10.
- A configured
heimrichhannot/contao-encore-bundleinstallation and asset build. Enable Encore for legacy page layouts too. - Project-provided
heimrichhannot/contao-ux-turbo-encore: activate eitherhuh_ux_turbo_encore(Drive) orhuh_ux_turbo_encore_no_drive. The chat does not import a second Turbo and does not change your Drive setting: see Turbo Drive and other scripts. - For Android keyboard resizing, the page layout needs
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">.
composer require heimrichhannot/contao-simple-member-chat php vendor/bin/contao-console contao:migrate
Install via Contao Manager alternatively. Rebuild the project's Encore assets
and clear its cache after installation. The bundle automatically enables
huh_member_chat on pages containing its content element. Use your project's
normal webpack command; this repository's .docs/build/phase-3a-webpack.cjs
is specific to the local demo, not a portable production configuration.
Create a regular published chat page with the Member chat content element,
a frontend login path, and appropriate page/member-group access. Select this
page in Chat page (memberChatPage) on its root page, including fallback
roots. Configure the provider's member groups; the default empty group list
intentionally exposes no contacts. Frontend modules are not needed.
Deliberate non-goals
Only 1:1 plain text: no groups, attachments, formatting/Markdown, emoji picker, reactions, user editing/deletion, block list, read-receipt UI or server push (WebSocket/Mercure/SSE). Unicode text, including typed emoji, is allowed. Muting suppresses unread counts and provides a push opt-out; messages still arrive. There are no content-element configuration fields or retention cron. Optional PWA push integration is included in this bundle and disabled by default.
Configuration
All values below are defaults; put overrides in the host's config/config.yaml.
contao_member_chat: contact_provider: member_groups polling: messages_interval: 4000 # milliseconds conversations_interval: 15000 # milliseconds badge_interval: 30000 # milliseconds max_interval_multiplier: 8 # maximum exponential backoff multiplier activity_throttle: 30 # seconds; 0 disables throttling message: max_length: 2000 # Unicode characters after normalization rate_limit: 30 # sends + new contact starts / minute / member rate_limiter: null # optional framework.rate_limiter service name list: page_size: 50 # messages/conversations per history window search_limit: 20 # maximum contacts search_min_length: 2 # Unicode characters, after trimming contact: avatar_field: null # binary file UUID column on tl_member avatar_size: [96, 96, 'crop'] # pixels + mode, or Contao image size ID providers: member_groups: groups: [] # positive tl_member_group IDs
Options are available through immutable Configuration\ChatOptions. Unknown
provider aliases fail at container compilation. Extra provider configuration
nodes are retained; custom providers must validate and wire their own options.
The built-in providers and their options are described under
Contact providers.
Activity-only updates to lastReadAt and lastPageId are throttled. Switching
chat pages can therefore leave the old deep-link destination until the throttle
expires (default 30 seconds). A newly delivered read advances immediately;
muting also changes list state immediately. lastReadAt is activity evidence,
not a precise presence signal or read receipt. Opening an existing conversation
does not consume the contact-start rate limit. Default limiter storage is
cache.app, with a sliding one-minute window.
Site-wide unread badge
{{ member_chat_unread_badge({class: 'nav__badge'}) }}
Activate huh_member_chat_badge plus one Turbo entry in every layout/page
using the function without a chat element, and rebuild assets. This entry loads
the shared polling script with no chat CSS. A chat page already loads that
script through huh_member_chat. The function does not activate assets while
rendering a late page template. Ensure badge-only pages also have
<meta name="turbo-cache-control" content="no-cache"> so Drive does not restore
an old navigation badge from a snapshot. Chat content-element pages add it
automatically. Authenticated pages must not be forced into a shared page cache.
There is one chat-unread frame per page. The function renders nothing without
a logged-in frontend member. At zero it renders an empty, pollable frame; at a
positive count it renders a translated accessible link (aria-label includes
the count), or a span if no destination can be resolved. Muted conversations
are excluded. aria-live="off" avoids repeated announcements. Theme the badge
with your navigation CSS. Without Turbo/the entry, the server value stays static.
There is deliberately no bare-number Twig function.
GET /_member_chat/unread is private/no-store and requires authentication
(401 otherwise). _locale preserves the embedding page language. Markup carries
data-chat-poll="badge", data-chat-mode="full", data-chat-url, interval and
maximum interval; responses never carry a self-referencing src. The poller
loads/reloads full frames and continues polling at zero. It pauses in hidden
tabs or hidden navigation containers and backs off after failures. Function
usage marks the complete response private/no-store, including anonymous renders.
Turbo Drive and other scripts
Frames and polling work with either Turbo entry. Switching between the conversation list and a conversation is a page navigation, and the bundle leaves the navigation model to the project:
- With
huh_ux_turbo_encore_no_drive, chat navigation links are markeddata-turbo="false"at runtime, so the browser performs an ordinary page load. Scripts that bind their handlers once per page load keep working. - With
huh_ux_turbo_encore, the project has opted into Drive site-wide and chat links behave like every other link on the site.
This matters because a link inside a <turbo-frame> is navigatable regardless of
the Drive setting. Without the explicit opt-out the chat would drive-navigate a
site that deliberately disabled Drive, and every script that binds on page load
would lose its handlers until a full reload. The push subscription button of
contao-pwa-bundle is one such script.
If you enable Drive, make your own scripts Turbo-aware: bind on turbo:load
rather than DOMContentLoaded, and re-resolve elements after each render.
Contact providers
A contact provider decides whom a member can find in the search and whom they
may start a conversation with. The bundle never assumes that every member may
contact every other member: exactly one provider is active, chosen by the
contact_provider option.
Built-in providers
Two providers ship with the bundle.
| Alias | Finds | Options |
|---|---|---|
member_groups |
members of the configured member groups | groups: list of tl_member_group IDs |
shared_groups |
members who share at least one active group with the viewer | none |
member_groups is the default. It answers the question "who belongs to the
groups this site allows chatting with", independent of the viewer's own groups.
That makes asymmetric setups possible: a support agent outside the group can
write to its members, while a member of the group cannot start a conversation
with the agent. Its groups option is empty by default and an empty list
matches nobody, so the search stays empty until the host configures group IDs:
contao_member_chat: contact_provider: member_groups providers: member_groups: groups: [2, 5] # positive tl_member_group IDs
shared_groups answers "who is in one of my groups". It needs no options and
is the natural choice when groups already model the circles that may talk to
each other, for example one group per department or per team. The relation is
symmetric: if two members share a group, both can find and contact each other.
contao_member_chat: contact_provider: shared_groups
Both built-ins behave identically in every other respect:
- Only accounts that may log in are offered:
disable = 0,login = 1, and thestart/stopwindow must include the current time. - The viewer's own group list, which
shared_groupsmatches against, counts only groups that are themselves active intl_member_group: a disabled or expired group drops out. The groups read from a target account are taken as stored, so group activity narrows who may search, not who may be found. - Search matches a literal prefix of a whole word in
firstname,lastnameorusername, soCarfindsChat Carolbutarolfinds nothing.%,_and the escape character are escaped, never interpreted. - Group membership is stored serialised in
tl_member.groups, so the group filter runs in PHP after the SQL filter. The result limit applies after that filter. This is fine at club or intranet scale and is the deliberate trade-off documented in the concept; it has not been benchmarked on large member bases. - An empty group list short-circuits: no query is issued, the search is empty and
canContact()is false.
Only starting a new conversation consults the provider. An existing conversation stays readable and writable even after the contact permission is revoked.
Writing your own provider
Implement Contact\ContactProviderInterface in an autowired/autoconfigured
service. Its interface attribute registers the provider; no manual service tag
is needed. For example, an application with its own contact directory can use:
namespace App\Chat; use HeimrichHannot\SimpleMemberChatBundle\Contact\ContactProviderInterface; use HeimrichHannot\SimpleMemberChatBundle\Contact\Viewer; use HeimrichHannot\SimpleMemberChatBundle\Domain\Contact; final readonly class TeamContacts implements ContactProviderInterface { public function __construct(private TeamDirectory $directory) {} public static function getAlias(): string { return 'teams'; } /** @return list<Contact> */ public function search(Viewer $viewer, string $query, int $limit): array { // Application service returns display DTOs for matching permitted members. return $this->directory->search($viewer->memberId, $viewer->groupIds, $query, $limit); } public function canContact(Viewer $viewer, int $memberId): bool { return $this->directory->mayContact($viewer->memberId, $memberId); } }
TeamDirectory is a project dependency to implement. Set
contact_provider: teams. Viewer holds memberId: int and active
groupIds: list<int>. Domain\Contact holds memberId, displayName, optional
subtitle and optional avatarUrl; groups never enter this display DTO.
ContactFactory::fromMemberModel(), fromMemberRow() and fromMemberRows()
provide the common name/avatar mapping; batch conversion resolves file UUIDs
in one query. Name fallback is first/last name, then username.
ContactService owns query trimming, minimum length, maximum limits, self
exclusion and deduplication. Providers must filter visibility, honor the supplied
limit, and implement the authoritative canContact() check. Permissions may
be asymmetric. Only starting a new conversation checks provider permission;
existing conversations stay readable/writable after access to that contact is
revoked. ContactResolver::resolveMany() independently resolves known members,
including inactive members; missing members become a translated placeholder
with memberId = 0. Result limits are caps, not a guarantee to fill every slot.
Template and view contract
Templates live under contao/templates/ in @Contao, with .twig-root.
Use Contao's template hierarchy to extend, rather than copy, templates:
{% extends '@Contao/content_element/member_chat.html.twig' %}
{% block conversation_header %}
<div class="project-chat-header">{{ parent() }}</div>
{% endblock %}
Content-element blocks: layout, search, conversations,
conversation_header, messages, compose, empty_state, login_hint.
Partials in @Contao/member_chat/ include conversation_list,
conversation_list_item, message_list, message, compose_form,
contact_results, contact_result, mute, load_more, new_messages,
day_separator, and unread_badge. Override partials for changes that must also
apply to standalone frame/stream responses. message_status is deliberately
empty; a project may render readByPartner there.
Readonly public view objects in View\Model:
| Object | Properties |
|---|---|
ChatView (view) |
conversations: list<ConversationItemView>, messages: list<MessageView>, partner: ?Contact, lastMessageId: int, changedAt: int, beforeMessageId: ?int, beforeConversation: ?string, muted: bool, conversationFingerprint: string |
ConversationItemView |
uuid: string, partner: Contact, url: string, excerpt: string, lastMessageAt: int, changedAt: int, unreadCount: int, muted: bool |
MessageView |
id: int, author: Contact, body: string, createdAt: int, own: bool, readByPartner: bool, daySeparator: ?DaySeparatorView, showAuthor: bool |
DaySeparatorView |
day: string (Y-m-d), label: string |
Timestamps are Unix seconds. Context includes page_id, language,
datim_format, options, request_token, conversation_uuid, back_url,
list_url, search_url, open_url, and for a selected conversation
messages_url, compose_url, mute_url, send_url; also embedded, history,
query, contacts, error, text. Anonymous content elements have view = null.
Roots/frames use attrs().mergeWith(...); partial-specific *_attributes
variables and chat_attributes allow classes/data attributes without replacing
markup. Badge attributes cannot replace its required ID/polling/accessibility
attributes. Never escape stored message text yourself: autoescaping and
member_chat_autolink|nl2br handle text and safe http/https links.
Preserve these script contracts when overriding markup:
- IDs:
chat-search,chat-conversations,chat-messages,chat-compose,chat-mute,chat-mute-button,chat-more-messages,chat-more-conversations,chat-unread;member-chat__*classes stay stable. - Polling:
data-chat-poll,data-chat-mode,data-chat-poll-interval,data-chat-poll-max-interval,data-chat-after,data-chat-since,data-chat-fingerprint,data-chat-page-size. Embedded chat frames alone setsrc/loading; responses must not point theirsrcback at themselves. - History:
data-chat-auto-load="false"disables automatic loading but leaves buttons usable. Preservedata-chat-load-more,data-chat-url,data-chat-before,data-chat-loaded-before,data-chat-message-id,data-chat-message-day,data-chat-day-message,data-chat-uuidand orderingdata-chat-last-message-at. Scroll state usesdata-chat-at-bottomanddata-chat-has-new; loading/focus/search attributes in supplied forms remain necessary. Keep accessible labels, log/live roles and error associations.
Messages use the earliest unseen after window in chronological order; full
pages drain until fewer than page_size messages arrive. Successful sends
never advance after, avoiding skipped concurrent incoming messages.
Conversation since is inclusive and unbounded against
GREATEST(lastMessageAt, viewer.tstamp). Same-second changes remain visible.
The optional fingerprint query argument and X-Chat-Fingerprint response
header accompany X-Chat-Since; matching/empty polls return 204. The fingerprint
covers standard view data. Extend it if a custom template depends on additional
changing state, or those changes may be suppressed. The initial frame seeds it.
History uses independent before cursors and X-Chat-Before (empty at
exhaustion), never advancing after, since or fingerprint. Conversation
history's (lastMessageAt,id) tie-breaker is the explicit exception to the
UUID-only navigation rule. X-Chat-After/X-Chat-Count describe delivered message
polls. Database history sorts by timestamp/ID; client equal-time list ordering
uses UUID, so equal-time ordering can differ. Day boundaries use the server
calendar/timezone; relative message time uses the page language and device
timezone, retaining the full datetime and tooltip.
Conversation list markup
Every part of a list item carries a class, so a project can style it without reaching for structural selectors:
| Element | Class |
|---|---|
| The link itself | member-chat__list-item |
| The open conversation | additionally member-chat__list-item--current and aria-current="page" |
| Avatar or initial | member-chat__avatar, initials additionally member-chat__avatar--initial |
| Partner name | member-chat__partner |
| Last message excerpt | member-chat__excerpt |
| Unread count | member-chat__status member-chat__unread |
| Muted marker | member-chat__status member-chat__muted |
The open conversation is marked server-side: the list frame URL carries the
current conversation as a current parameter, so polls and history loads keep
the marker. --member-chat-current-bg, --member-chat-unread-bg and
--member-chat-unread-fg set the default colours.
Sender names
A one to one conversation has exactly one possible sender per side, so the
author block renders the name visually hidden: the live region still names
the sender for screen readers, but nothing is repeated above every bubble.
MessageView::showAuthor carries the decision and is true as soon as a
conversation has more than two participants, which is where group conversations
will pick it up without touching the template.
To show names in one to one conversations as well, override the author block
and drop the member-chat__visually-hidden class, or remove that class in your
own CSS.
Styling
Most rules are in the member-chat cascade layer. Project styles can override
them; narrow unlayered rules protect the compact header, history minimum,
compose area and pressed mute state against broad theme heading rules. Set
properties on .member-chat; maintain accessible contrast after overrides.
Custom property (prefix --member-chat-) |
Default |
|---|---|
offset-top, sidebar-width, gap, radius |
0px, 20rem, 1rem, .5rem |
bubble-own-bg, bubble-bg, bubble-fg |
#dbeafe, #f1f5f9, #172033 |
accent, background, border, control-fg |
#174ea6, #fff, #64748b, #fff |
load-more-bg, load-more-fg |
accent, control-fg |
new-messages-bg, new-messages-fg |
accent, control-fg |
day-fg |
#475569 |
history-min-height |
12rem |
header-font-size, header-line-height, header-gap |
1rem, 1.25, .5rem |
header-control-font-size, header-control-padding |
.875rem, .5rem |
muted-bg, muted-fg |
#334155, #fff |
--member-chat-viewport-height is measured by JavaScript on single-column chat
pages and falls back to 100dvh; offset-top is subtracted. This measured
property is the intentional exception to the no-inline-style convention.
CSS determines the layout; the script detects sidebar visibility. The supplied
768px media rule for .member-chat changes to
grid-template-columns: var(--member-chat-sidebar-width) minmax(0, 1fr) and shows
the sidebar/empty state. Override that rule and the visibility rules to move
the breakpoint or use a single column on desktop; media queries cannot use
custom properties. Alternatively disable entry CSS through Encore settings.
Very short viewports can scroll the document to keep minimum history and
controls reachable. No-JavaScript pages retain server rendering and height
fallback. Manual/device checks are in .docs/BROWSER_CHECKLIST.md.
URLs, events and integrations
Service\ConversationUrlGenerator is the single URL builder:
forConversation(Conversation $conversation, int $memberId, int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): ?stringchecks that conversation participant'slastPageIdfirst. PassABSOLUTE_URLfor delivery outside a web page; push does this per recipient.listPage(int $memberId): ?stringuses the latest positive tracked page bylastReadAt, then participant ID, across the member's conversations.- Both fall back to the first published root with a usable
memberChatPage, ordered by root sorting then ID, then returnnull. Deleted, unpublished, time-restricted and non-regular destinations are rejected, including an unpublished root. Preview mode never enables unpublished deep links. generate(PageModel $page, ?string $uuid = null, int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH)preserves an already known content-element page without a query per list row. Explicit empty parameters clear an existing auto-item. URL generation does not grant access or verify that an editor placed a chat element on the selected page.
Stable integration payloads: Domain\Conversation (id, uuid, memberLow,
memberHigh, createdAt, lastMessageAt, lastMessageId) and Domain\Message
(id, conversationId, authorId, body, createdAt). UUIDs are public
navigation identifiers; integer IDs are server integration/storage keys.
| Stable event | Payload |
|---|---|
Event\MessageSentEvent |
message, conversation, authorId, recipientIds: list<int> |
Event\ConversationCreatedEvent |
conversation, initiatorId |
Event\MessagesReadEvent |
conversationId, memberId, upToMessageId |
Use Symfony #[AsEventListener]. Events dispatch after the owning transaction
commits; listener failures are logged and cannot roll back a send. Conversation
creation dispatches only for a new row; reading dispatches only on read-position
advance. There are no extra events for mute/erasure. Services reject nested
transactions.
Gateway\MessageGateway::find(int): ?Message (also on its interface) returns
null after deletion. Stable read-only
Gateway\ParticipantGatewayInterface::state(int $conversationId, int $memberId)
returns null for a removed participant, otherwise
{lastReadAt: int, lastReadMessageId: int, lastPageId: int, muted: bool}. This
existing narrow accessor avoids a second wrapper service or exposing SQL.
The optional push handler below reloads messages and participant state before
delivery. The event recipient list limits delivery; current participant rows
determine whether a queued recipient is still eligible.
Optional PWA push
The chat works unchanged without heimrichhannot/contao-pwa-bundle: no push
services are registered unless its sender, notification and model classes are
available. It is a Composer suggestion, not a production requirement. The
bundle's development dependency tests the real PWA 0.10 notification API with a
mock sender. No separate bridge package is needed.
Install and enable the PWA bundle, install a compatible minishlink/web-push
version (PWA only suggests it), configure PWA VAPID credentials, and enable push
on a PWA configuration. Devices must grant permission and subscribe while
logged in so subscriptions contain the receiving member's ID. Then configure:
contao_member_chat: push: enabled: true configuration: 3 # existing tl_pwa_configurations ID with supportPush enabled active_recipient_grace: 60 body_length: 0
Option under push |
Default | Unit and meaning |
|---|---|---|
enabled |
false |
Boolean; enables enqueueing and handling when PWA is available. Disabling also suppresses already queued messages. |
configuration |
0 |
Integer configuration ID; 0 means no delivery. Choose one positive ID with push enabled. Only that configuration's subscriptions are eligible. |
active_recipient_grace |
60 |
Seconds; suppress recipients with positive lastReadAt at or after worker time minus this value. 0 disables activity suppression. |
body_length |
0 |
UTF-8 characters, 0–500; 0 omits message text entirely, otherwise takes the first N characters without an appended ellipsis. |
A fixed configuration ID keeps delivery within the intended PWA/site, including on installations with several configurations. There is no automatic broadcast across configurations. Missing/disabled configurations and empty subscriber lists result in no delivery.
The post-commit event listener only enqueues message and recipient IDs. The
queued message declares #[AsMessage('contao_prio_low')], which Contao Managed
Edition routes to the Doctrine contao_prio_low transport; keep that
asynchronous routing if overriding Messenger configuration. Contao's configured
web/cron workers, or bin/console messenger:consume contao_prio_low, process
the queue. The deprecated LowPriorityMessageInterface is deliberately not
used: it stops working in Contao 6, and the core's own messages already use the
attribute.
Set the domain of your root page. Contao builds the absolute deep link from
it. Without a domain, absolute URLs fall back to the router request context,
which has no request inside a Messenger worker and then resolves to localhost.
The bundle therefore carries the scheme and host of the sending request in the
queued message and anchors the link to it when the root page has no domain. If
neither is available, the push is sent without a deep link rather than with
an unusable one.
At execution time the handler reloads the message/conversation and each event recipient's participant state. Removed, muted, recently active recipients and the author are skipped. Other participants not in the event are never added. No additional permanent already-read suppression is applied: a delayed message can notify after the activity grace expires. Failures are logged and swallowed, so they neither roll back chat messages nor trigger automatic push retries; remaining recipients are still attempted. Delivery is best effort, not exactly once, and a manually redelivered queue message can notify again.
Activity writes are throttled by polling.activity_throttle (default 30 seconds).
lastReadAt can therefore lag actual activity by up to that interval, plus
poll/network delays. A grace shorter than the throttle may notify someone who
is actively reading. The 60-second default provides room for the default
throttle and normal 4-second polling, but cannot guarantee suppression during
browser suspension or network delays.
The title is the sender's display name from ContactResolver. Push payloads
leave the chat server and reach device notification systems, potentially
including a lock screen. With a positive body_length, they also contain private
message text. The default omits text, but still includes the sender name and,
when resolvable, the conversation URL. Review this before opting into excerpts.
The chat's own error context contains IDs and an exception, not a copied body;
the PWA sender also writes its delivery diagnostics to the application logger.
The click target uses the recipient's current lastPageId or the existing
published-root fallback, rendered as an absolute URL in data.clickJumpTo.
Configure root domains and a correct router default_uri for CLI workers where
needed. No resolvable destination means a notification without a deep link.
Opening a URL still requires the normal frontend login/access checks.
Real push delivery is not covered by automated tests. Acceptance requires a running worker, valid VAPID keys, a subscribed device and its service worker, and confirmation of notification display, clicking/login/deep-link routing, muting and activity suppression on the target platforms.
Moderation and privacy
Backend Accounts → Member chat lists member pairs and last-message times. Its child list shows author, time and a 100-character plain-text excerpt. Only viewing/deleting is available; module access uses ordinary backend group permissions. Participants have no module. Member names are batch-resolved per request/table; no member query per row. For the expected small-site scale, this batch includes distinct member IDs across that table, not just the visible page.
Deleting a conversation cascades through messages and participants. Deleting a
message repairs the last-message pointer/time, including resetting both to zero
for an empty conversation. Contao invokes ondelete before SQL DELETE, so the
repair excludes the pending row. Already open message frames do not receive
delete/tombstone streams; reload to remove moderated text. Contao's undo storage
may retain deleted content; account for core backups/undo in your privacy policy.
Restoration via Undo and concurrent moderation are not verified.
Member deletion anonymizes message authors to 0, removes their participant
row and leaves the other member's history readable but read-only. The text is
retained and may itself contain personal data; anonymization is not content
redaction. With no participants left, conversation and messages are removed.
The original ordered member-pair IDs remain on a surviving conversation.
Covered paths: backend member deletion; the new close-account content element's
CloseAccountEvent; the legacy closeAccount hook, both only for close_delete.
close_deactivate does nothing. Direct SQL, external privacy tools and arbitrary
custom deletion code are not covered; integrate MemberDataEraser explicitly.
There is no automatic retention policy.
Verification limits
Local DDEV verification and exact outputs are recorded in the phase 4 report. The concept records reviewer-confirmed phase 3c desktop/375px behavior; this phase does not claim a new browser session. Real iOS/Android keyboards, standalone PWA, screen-reader announcements, project template/CSS overrides, backend rights/deletion UI, badge-only navigation behavior, production asset builds, hosted CI and push delivery remain manual or future acceptance items. Contact/group filtering and backend batch labeling target small sites; no large-scale benchmark is claimed.
