Skip to content

Realtime typing indicator ignores user.viewLastSeenAt, so moderators see [Anonymous] instead of the typing user #4879

Description

@ekumanov

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

  1. Install Flarum 2.0 with the Realtime extension enabled and the "Typing indicator" setting on
    (default).
  2. 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.)
  3. As user A (a regular member), go to Settings → Privacy and disable
    "Allow others to see when I am online" (discloseOnline).
  4. As user A, open a discussion and start typing a reply.
  5. 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):

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions