Skip to main content

Presence & Chat

Build a live roster + chat with no WebRTC. You can use either class:

  • SignallingClient if you want pub/sub across multiple channels and a raw presence roster.
  • MeteredPeer if you want onPeerJoined / onPeerLeft per-peer objects (and might add media later).

This guide uses SignallingClient — it's the smaller surface and the natural fit for chat.

The roster

The first onPresence event after a subscribe is the authoritative snapshot of everyone already in the channel (always sent, even when empty). Subsequent events are deltas.

final client = SignallingClient(SignallingClientOptions(
tokenProvider: mintJwt, // peerMetadata: { username, avatarUrl }
));

final roster = <String, Map<String, Object?>?>{}; // peerId → metadata

client.onPresence.listen((e) {
if (e.channel != 'lobby') return;
for (final p in e.joined) roster[p.peerId] = p.metadata;
for (final p in e.left) roster.remove(p.peerId);
rebuildRosterUi();
});

await client.connect();
await client.subscribe('lobby');

Names + avatars come from each peer's JWT peerMetadata (see Authentication). With a pk_ key there's no peerMetadata, so p.metadata is null and you only get opaque peerIds.

Broadcast chat

publish to the channel, receive everyone's on onMessage.

await client.subscribe('lobby', const SubscribeOptions(includeSenderMetadata: true));

client.onMessage.listen((m) {
if (m.channel != 'lobby') return;
final name = m.fromMetadata?['username'] ?? m.from;
appendMessage(name, (m.data as Map)['text']);
});

Future<void> sendChat(String text) =>
client.publish('lobby', {'type': 'chat', 'text': text});

includeSenderMetadata: true makes each broadcast carry the sender's peerMetadata in fromMetadata, so you can render names without keeping a separate roster lookup. (It's off by default because metadata can be large and most subscribers don't need it per-message.)

Direct (one-to-one) messages

send(peerId, data) routes to one peer; they receive it on onDirect.

client.onDirect.listen((m) {
final name = m.fromMetadata?['username'] ?? m.from;
appendPrivateMessage(name, m.data);
});

await client.send(otherPeerId, {'type': 'dm', 'text': 'hey'});

Directs always carry fromMetadata when the sender's JWT had peerMetadata (no opt-in needed).

Typing indicators, reactions, presence pings

Anything ephemeral is just another broadcast data shape — no separate API:

Future<void> setTyping(bool typing) =>
client.publish('lobby', {'type': 'typing', 'typing': typing});

client.onMessage.listen((m) {
final data = m.data;
if (data is Map && data['type'] == 'typing') {
setPeerTyping(m.from, data['typing'] == true);
}
});

Multiple channels on one connection

Unlike MeteredPeer (one channel per instance), one SignallingClient can subscribe to many — a lobby, a DM thread, a notifications channel:

await client.subscribe('lobby');
await client.subscribe('dm/$myId');
await client.subscribe('announcements');

onMessage fires for all of them — branch on m.channel. They're all re-subscribed automatically after a reconnect (autoResubscribe, on by default).

Security

  • Trust m.from (server-stamped envelope sender), never an inner data['from'] a peer could spoof.
  • fromMetadata / metadata come from the sender's JWT — their origin is your backend, but a leaked JWT keeps using them. Use the JWT's channels claim for access control, not metadata fields.
  • Maps from jsonDecode are plain Maps — read known keys into your model rather than blindly merging untrusted input.

Lifecycle

In a widget, call dispose() (not close()) to release the stream controllers:

@override
void dispose() {
client.dispose();
super.dispose();
}

See also