Presence & Chat
Build a live roster + chat with no WebRTC. You can use either class:
SignallingClientif you want pub/sub across multiple channels and a raw presence roster.MeteredPeerif you wantonPeerJoined/onPeerLeftper-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 innerdata['from']a peer could spoof. fromMetadata/metadatacome from the sender's JWT — their origin is your backend, but a leaked JWT keeps using them. Use the JWT'schannelsclaim for access control, not metadata fields.- Maps from
jsonDecodeare plainMaps — 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
SignallingClientreference — every stream + method- Authentication — minting
peerMetadatafor names/avatars - WebRTC Video Call — add media to the same channel later