Current Behavior
Core treats user.viewLastSeenAt ("Always view user last seen time") as the override for a user's
discloseOnline preference: a user who has hidden their online status still has their lastSeenAt
disclosed to actors holding that permission.
UserResource:
Schema\DateTime::make('lastSeenAt')
->visible(fn (User $user, Context $context) =>
$user->getPreference('discloseOnline') || $context->getActor()->can('viewLastSeenAt', $user)
)
The realtime typing indicator is the one place where that override is not applied. It anonymises at
the sender, unconditionally, so the identity never reaches anyone — including actors who are
explicitly permitted to see through discloseOnline.
extensions/realtime/js/src/forum/extend/Discussion/TypingIndicator.tsx:
this.actorIsTyping = (): void => {
const discloseOnline = app.session.user?.preferences()?.discloseOnline;
app.websocket_channels.discussion?.trigger('client-typing', {
displayName: discloseOnline ? app.session.user?.displayName() : '[anonymous]',
discloseOnline,
time: Date.now(),
});
};
The result is that a moderator with both discussion.flarum-realtime.view-who-types and
user.viewLastSeenAt sees [Anonymous] is typing, while the same moderator can see that user's
last-seen time on their profile. The two disclosures are inconsistent.
This is not fixable in a theme or extension: the sender scrubs the name before it is ever
broadcast, and private-typing={id} has a single audience — everyone who can see the discussion
(AuthController::typing() checks only Discussion::whereVisibleTo). Simply broadcasting the real
name and filtering in the client would expose it to every reader over the websocket, so the routing
has to change server-side.
Steps to Reproduce
- Install Flarum 2.0 with the Realtime extension enabled and the "Typing indicator" setting on
(default).
- In Admin → Permissions, grant
discussion.flarum-realtime.view-who-types to Moderators.
(user.viewLastSeenAt is already seeded to Moderators by
2018_07_21_000100_seed_default_group_permissions.php.)
- As user A (a regular member), go to Settings → Privacy and disable
"Allow others to see when I am online" (discloseOnline).
- As user A, open a discussion and start typing a reply.
- As user B (a Moderator), view the same discussion in another browser.
Observed: B sees [Anonymous] is typing.
Also observed: B can see A's last-seen time on A's profile, i.e. the same override that applies
there does not apply here.
Expected Behavior
An actor holding user.viewLastSeenAt should see who is typing, even when that user has hidden
their online status — consistent with how the same permission already overrides discloseOnline
for lastSeenAt. Actors without the permission should continue to see [Anonymous], and the
identity should never reach their socket.
Environment
- Flarum version: 2.0.0-rc.5, and unchanged on current
2.x (2553eb514)
- Webserver: nginx
- Hosting environment: vps
- PHP version: 8.4
- Browser: not browser-specific
The behaviour above is structural rather than environmental — the sender scrubs the display name
before the event is ever broadcast, so there is no configuration under which a permitted actor
receives it.
Possible Solution
Happy to open a PR. Sketch, reusing two patterns already in the extension — an AuthController-gated
channel (as private-index-typing-tag= does) and server-side re-routing of a client event in
Message (as relayIndexTyping() / relayComposeTyping() do):
-
New permission-gated channel private-typingIdentified={discussionId}. The existing
dispatcher regex ~^private-(?<subject>[a-zA-Z]+)=(?<id>[0-9]+)$~ already matches a camelCase
subject, so this needs no regex change — only a new method:
protected function typingIdentified(int $id): bool
{
return $this->actor->hasPermission('user.viewLastSeenAt')
&& Discussion::whereVisibleTo($this->actor)->where('id', $id)->exists();
}
Permission evaluation happens once per subscription in a normal HTTP request with a real actor,
so the websocket process does no permission work in the hot path.
-
Split the relay in Message::respond() when a client-typing event carries a
non-disclosing sender:
- full payload →
private-typingIdentified={id};
- anonymised payload (
displayName: null) → private-typing={id}, excluding the sender and
every socket subscribed to the identified channel.
The exclusion means a privileged subscriber (who is on both channels) receives exactly one
event, so no client-side deduplication is needed. When the sender is disclosing, the current
single broadcast is unchanged. When nobody privileged is listening the identified channel does
not exist and behaviour is identical to today.
-
Derive the sender's identity server-side rather than trusting the payload. Gating a name
behind a moderator permission makes an authority claim, so the name should not be a
client-asserted string. Manager can index socketId → userId when a connection subscribes to
private-user={id} — an authenticated identity claim, since AuthController::user() requires
actor->id === id and subscription verifies the HMAC — and Message can then read
display_name and discloseOnline from the database (short TTL cache) and overwrite both
fields before relaying. This keeps the existing payload shape, so third-party client-typing
listeners and the copy in flarum/messages are unaffected. Senders that cannot be identified
(guests, or the brief window after a forceReconnect before the user channel re-subscribes)
fail closed to anonymous. As a side effect this also closes the existing ability for a modified
client to broadcast an arbitrary displayName.
-
Frontend: a canViewHiddenTypers boolean on UserResource (visible to self only, mirroring
the existing canViewWhoTypes field), used to decide whether to subscribe to the identified
channel; TypingState.add() anonymises only when the payload carries no name. I would render
the disclosed name marked rather than bare — e.g. Bob (hidden) — so a moderator knows that
person is invisible to everyone else.
Roughly 300 lines plus regression tests, which would follow the existing
tests/unit/Websocket/MessageTest.php (relay routing) and
tests/integration/api/IndexTypingTagAuthTest.php (channel auth by permission).
Additional Context
- Defaults are unchanged for regular members.
user.viewLastSeenAt is seeded to Moderators
only, so on a default install this discloses nothing that is not already disclosed to that same
group via lastSeenAt. I do not think it warrants a separate setting, but happy to add one
(mirroring index-typing-indicator-restricted) if you would rather it were opt-in.
- Semantics are the part worth your call, not the mechanism: whether
user.viewLastSeenAt
should imply "may see who is typing while hidden", or whether typing identity deserves its own
permission (e.g. flarum-realtime.view-hidden-typers). I have assumed reuse, since that is what
admins already reach for when they want moderators to see through hidden online status, and it
needs no migration — but I will follow whichever you prefer.
- Like the index typing dots, this lives in the relay and therefore assumes the bundled websocket
server.
flarum/messages has its own copy of the typing indicator, which currently drops non-disclosing
typers entirely rather than showing [Anonymous]. I have deliberately left it out of scope; it
could follow the same shape in a separate PR if this one is accepted.
Current Behavior
Core treats
user.viewLastSeenAt("Always view user last seen time") as the override for a user'sdiscloseOnlinepreference: a user who has hidden their online status still has theirlastSeenAtdisclosed to actors holding that permission.
UserResource:The realtime typing indicator is the one place where that override is not applied. It anonymises at
the sender, unconditionally, so the identity never reaches anyone — including actors who are
explicitly permitted to see through
discloseOnline.extensions/realtime/js/src/forum/extend/Discussion/TypingIndicator.tsx:The result is that a moderator with both
discussion.flarum-realtime.view-who-typesanduser.viewLastSeenAtsees[Anonymous] is typing, while the same moderator can see that user'slast-seen time on their profile. The two disclosures are inconsistent.
This is not fixable in a theme or extension: the sender scrubs the name before it is ever
broadcast, and
private-typing={id}has a single audience — everyone who can see the discussion(
AuthController::typing()checks onlyDiscussion::whereVisibleTo). Simply broadcasting the realname and filtering in the client would expose it to every reader over the websocket, so the routing
has to change server-side.
Steps to Reproduce
(default).
discussion.flarum-realtime.view-who-typesto Moderators.(
user.viewLastSeenAtis already seeded to Moderators by2018_07_21_000100_seed_default_group_permissions.php.)"Allow others to see when I am online" (
discloseOnline).Observed: B sees
[Anonymous] is typing.Also observed: B can see A's last-seen time on A's profile, i.e. the same override that applies
there does not apply here.
Expected Behavior
An actor holding
user.viewLastSeenAtshould see who is typing, even when that user has hiddentheir online status — consistent with how the same permission already overrides
discloseOnlinefor
lastSeenAt. Actors without the permission should continue to see[Anonymous], and theidentity should never reach their socket.
Environment
2.x(2553eb514)The behaviour above is structural rather than environmental — the sender scrubs the display name
before the event is ever broadcast, so there is no configuration under which a permitted actor
receives it.
Possible Solution
Happy to open a PR. Sketch, reusing two patterns already in the extension — an AuthController-gated
channel (as
private-index-typing-tag=does) and server-side re-routing of a client event inMessage(asrelayIndexTyping()/relayComposeTyping()do):New permission-gated channel
private-typingIdentified={discussionId}. The existingdispatcher regex
~^private-(?<subject>[a-zA-Z]+)=(?<id>[0-9]+)$~already matches a camelCasesubject, so this needs no regex change — only a new method:
Permission evaluation happens once per subscription in a normal HTTP request with a real actor,
so the websocket process does no permission work in the hot path.
Split the relay in
Message::respond()when aclient-typingevent carries anon-disclosing sender:
private-typingIdentified={id};displayName: null) →private-typing={id}, excluding the sender andevery socket subscribed to the identified channel.
The exclusion means a privileged subscriber (who is on both channels) receives exactly one
event, so no client-side deduplication is needed. When the sender is disclosing, the current
single broadcast is unchanged. When nobody privileged is listening the identified channel does
not exist and behaviour is identical to today.
Derive the sender's identity server-side rather than trusting the payload. Gating a name
behind a moderator permission makes an authority claim, so the name should not be a
client-asserted string.
Managercan indexsocketId → userIdwhen a connection subscribes toprivate-user={id}— an authenticated identity claim, sinceAuthController::user()requiresactor->id === idand subscription verifies the HMAC — andMessagecan then readdisplay_nameanddiscloseOnlinefrom the database (short TTL cache) and overwrite bothfields before relaying. This keeps the existing payload shape, so third-party
client-typinglisteners and the copy in
flarum/messagesare unaffected. Senders that cannot be identified(guests, or the brief window after a
forceReconnectbefore the user channel re-subscribes)fail closed to anonymous. As a side effect this also closes the existing ability for a modified
client to broadcast an arbitrary
displayName.Frontend: a
canViewHiddenTypersboolean onUserResource(visible to self only, mirroringthe existing
canViewWhoTypesfield), used to decide whether to subscribe to the identifiedchannel;
TypingState.add()anonymises only when the payload carries no name. I would renderthe disclosed name marked rather than bare — e.g.
Bob (hidden)— so a moderator knows thatperson is invisible to everyone else.
Roughly 300 lines plus regression tests, which would follow the existing
tests/unit/Websocket/MessageTest.php(relay routing) andtests/integration/api/IndexTypingTagAuthTest.php(channel auth by permission).Additional Context
user.viewLastSeenAtis seeded to Moderatorsonly, so on a default install this discloses nothing that is not already disclosed to that same
group via
lastSeenAt. I do not think it warrants a separate setting, but happy to add one(mirroring
index-typing-indicator-restricted) if you would rather it were opt-in.user.viewLastSeenAtshould imply "may see who is typing while hidden", or whether typing identity deserves its own
permission (e.g.
flarum-realtime.view-hidden-typers). I have assumed reuse, since that is whatadmins already reach for when they want moderators to see through hidden online status, and it
needs no migration — but I will follow whichever you prefer.
server.
flarum/messageshas its own copy of the typing indicator, which currently drops non-disclosingtypers entirely rather than showing
[Anonymous]. I have deliberately left it out of scope; itcould follow the same shape in a separate PR if this one is accepted.