From 4a32a14a913168f912b384537e59b59d463f66a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Fri, 26 Sep 2025 22:42:19 +0100 Subject: [PATCH 1/7] fix: add presence flag --- .../lib/src/realtime_channel.dart | 6 +- packages/realtime_client/lib/src/types.dart | 5 + .../realtime_client/test/channel_test.dart | 92 ++++++++++++++++++- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/realtime_client/lib/src/realtime_channel.dart b/packages/realtime_client/lib/src/realtime_channel.dart index 02c7aa864..1455b3a68 100644 --- a/packages/realtime_client/lib/src/realtime_channel.dart +++ b/packages/realtime_client/lib/src/realtime_channel.dart @@ -130,10 +130,14 @@ class RealtimeChannel { if (callback != null) callback(RealtimeSubscribeStatus.closed, null); }); + final presenceEnabled = + (_bindings['presence']?.isNotEmpty == true) || + (params['config']['presence']['enabled'] == true); + final accessTokenPayload = {}; final config = { 'broadcast': broadcast, - 'presence': presence, + 'presence': {...presence, 'enabled': presenceEnabled}, 'postgres_changes': _bindings['postgres_changes']?.map((r) => r.filter).toList() ?? [], 'private': isPrivate == true, diff --git a/packages/realtime_client/lib/src/types.dart b/packages/realtime_client/lib/src/types.dart index 32a0cea25..e3597df37 100644 --- a/packages/realtime_client/lib/src/types.dart +++ b/packages/realtime_client/lib/src/types.dart @@ -148,6 +148,9 @@ class RealtimeChannelConfig { /// [key] option is used to track presence payload across clients final String key; + /// Enables presence even without presence bindings + final bool enabled; + /// Defines if the channel is private or not and if RLS policies will be used to check data final bool private; @@ -155,6 +158,7 @@ class RealtimeChannelConfig { this.ack = false, this.self = false, this.key = '', + this.enabled = false, this.private = false, }); @@ -167,6 +171,7 @@ class RealtimeChannelConfig { }, 'presence': { 'key': key, + 'enabled': enabled, }, 'private': private, } diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 3cb034419..58ad14de7 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -34,7 +34,7 @@ void main() { expect(channel.params, { 'config': { 'broadcast': {'ack': false, 'self': false}, - 'presence': {'key': ''}, + 'presence': {'key': '', 'enabled': false}, 'private': false, } }); @@ -54,7 +54,7 @@ void main() { expect(joinPush.payload, { 'config': { 'broadcast': {'ack': false, 'self': false}, - 'presence': {'key': ''}, + 'presence': {'key': '', 'enabled': false}, 'private': true, }, }); @@ -386,4 +386,92 @@ void main() { expect(leaveCalled, isTrue); }); }); + + group('presence enabled', () { + setUp(() { + socket = RealtimeClient('', timeout: const Duration(milliseconds: 1234)); + }); + + test('should enable presence when config.presence.enabled is true even without bindings', () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(enabled: true), + ); + + channel.subscribe(); + + final joinPayload = channel.joinPush.payload; + expect(joinPayload['config']['presence']['enabled'], isTrue); + }); + + test('should enable presence when presence listeners exist', () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.onPresenceSync((payload) {}); + channel.subscribe(); + + final joinPayload = channel.joinPush.payload; + expect(joinPayload['config']['presence']['enabled'], isTrue); + }); + + test('should enable presence when both bindings exist and config.presence.enabled is true', () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(enabled: true), + ); + + channel.onPresenceSync((payload) {}); + channel.subscribe(); + + final joinPayload = channel.joinPush.payload; + expect(joinPayload['config']['presence']['enabled'], isTrue); + }); + + test('should not enable presence when neither bindings exist nor config.presence.enabled is true', () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.subscribe(); + + final joinPayload = channel.joinPush.payload; + expect(joinPayload['config']['presence']['enabled'], isFalse); + }); + + test('should enable presence when join listener exists', () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.onPresenceJoin((payload) {}); + channel.subscribe(); + + final joinPayload = channel.joinPush.payload; + expect(joinPayload['config']['presence']['enabled'], isTrue); + }); + + test('should enable presence when leave listener exists', () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.onPresenceLeave((payload) {}); + channel.subscribe(); + + final joinPayload = channel.joinPush.payload; + expect(joinPayload['config']['presence']['enabled'], isTrue); + }); + }); } From dd788082a7ea3f14442e6f6db0cc21494d1bdffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Mon, 29 Sep 2025 11:45:09 +0100 Subject: [PATCH 2/7] fix test --- packages/realtime_client/test/socket_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/realtime_client/test/socket_test.dart b/packages/realtime_client/test/socket_test.dart index ac36c1e31..7754422f3 100644 --- a/packages/realtime_client/test/socket_test.dart +++ b/packages/realtime_client/test/socket_test.dart @@ -354,7 +354,7 @@ void main() { expect(channel.params, { 'config': { 'broadcast': {'ack': false, 'self': false}, - 'presence': {'key': ''}, + 'presence': {'key': '', 'enabled': false}, 'private': false, } }); From a6472c697aaad01673dc938af7c1e1d87673a06a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Mon, 29 Sep 2025 13:58:13 +0100 Subject: [PATCH 3/7] fix format --- .../realtime_client/lib/src/realtime_channel.dart | 3 +-- packages/realtime_client/lib/src/transformers.dart | 2 +- packages/realtime_client/test/channel_test.dart | 12 +++++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/realtime_client/lib/src/realtime_channel.dart b/packages/realtime_client/lib/src/realtime_channel.dart index 1455b3a68..e43825ccd 100644 --- a/packages/realtime_client/lib/src/realtime_channel.dart +++ b/packages/realtime_client/lib/src/realtime_channel.dart @@ -130,8 +130,7 @@ class RealtimeChannel { if (callback != null) callback(RealtimeSubscribeStatus.closed, null); }); - final presenceEnabled = - (_bindings['presence']?.isNotEmpty == true) || + final presenceEnabled = (_bindings['presence']?.isNotEmpty == true) || (params['config']['presence']['enabled'] == true); final accessTokenPayload = {}; diff --git a/packages/realtime_client/lib/src/transformers.dart b/packages/realtime_client/lib/src/transformers.dart index 383d87571..f45372eb0 100644 --- a/packages/realtime_client/lib/src/transformers.dart +++ b/packages/realtime_client/lib/src/transformers.dart @@ -167,7 +167,7 @@ dynamic convertCell(String type, dynamic value) { case PostgresTypes.text: case PostgresTypes.time: // To allow users to cast it based on Timezone case PostgresTypes - .timestamptz: // To allow users to cast it based on Timezone + .timestamptz: // To allow users to cast it based on Timezone case PostgresTypes.timetz: // To allow users to cast it based on Timezone case PostgresTypes.tsrange: case PostgresTypes.tstzrange: diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 58ad14de7..c926baf89 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -392,7 +392,9 @@ void main() { socket = RealtimeClient('', timeout: const Duration(milliseconds: 1234)); }); - test('should enable presence when config.presence.enabled is true even without bindings', () { + test( + 'should enable presence when config.presence.enabled is true even without bindings', + () { channel = RealtimeChannel( 'topic', socket, @@ -419,7 +421,9 @@ void main() { expect(joinPayload['config']['presence']['enabled'], isTrue); }); - test('should enable presence when both bindings exist and config.presence.enabled is true', () { + test( + 'should enable presence when both bindings exist and config.presence.enabled is true', + () { channel = RealtimeChannel( 'topic', socket, @@ -433,7 +437,9 @@ void main() { expect(joinPayload['config']['presence']['enabled'], isTrue); }); - test('should not enable presence when neither bindings exist nor config.presence.enabled is true', () { + test( + 'should not enable presence when neither bindings exist nor config.presence.enabled is true', + () { channel = RealtimeChannel( 'topic', socket, From 0da95b189e7687b06c59faacd4b5fb2f9fdf2268 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 29 Sep 2025 10:48:45 -0300 Subject: [PATCH 4/7] style: dart format --- packages/realtime_client/lib/src/transformers.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/realtime_client/lib/src/transformers.dart b/packages/realtime_client/lib/src/transformers.dart index f45372eb0..383d87571 100644 --- a/packages/realtime_client/lib/src/transformers.dart +++ b/packages/realtime_client/lib/src/transformers.dart @@ -167,7 +167,7 @@ dynamic convertCell(String type, dynamic value) { case PostgresTypes.text: case PostgresTypes.time: // To allow users to cast it based on Timezone case PostgresTypes - .timestamptz: // To allow users to cast it based on Timezone + .timestamptz: // To allow users to cast it based on Timezone case PostgresTypes.timetz: // To allow users to cast it based on Timezone case PostgresTypes.tsrange: case PostgresTypes.tstzrange: From ac85a73c01f36ff746a7faedbf8e60d5d88b84d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Mon, 29 Sep 2025 15:54:08 +0100 Subject: [PATCH 5/7] add resubscribe --- .../lib/src/realtime_channel.dart | 34 ++++- .../realtime_client/test/channel_test.dart | 131 ++++++++++++++++++ 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/packages/realtime_client/lib/src/realtime_channel.dart b/packages/realtime_client/lib/src/realtime_channel.dart index e43825ccd..fdfd30960 100644 --- a/packages/realtime_client/lib/src/realtime_channel.dart +++ b/packages/realtime_client/lib/src/realtime_channel.dart @@ -104,6 +104,25 @@ class RealtimeChannel { } } + bool _shouldEnablePresence() { + return (_bindings['presence']?.isNotEmpty == true) || + (params['config']['presence']['enabled'] == true); + } + + void _handlePresenceUpdate() { + if (joinedOnce && isJoined) { + final currentPresenceEnabled = params['config']['presence']['enabled']; + final shouldEnablePresence = _shouldEnablePresence(); + + if (!currentPresenceEnabled && shouldEnablePresence) { + final config = Map.from(params['config']); + config['presence']['enabled'] = true; + updateJoinPayload({'config': config}); + rejoin(); + } + } + } + /// Subscribes to receive real-time changes /// /// Pass a [callback] to react to different status changes. @@ -130,8 +149,7 @@ class RealtimeChannel { if (callback != null) callback(RealtimeSubscribeStatus.closed, null); }); - final presenceEnabled = (_bindings['presence']?.isNotEmpty == true) || - (params['config']['presence']['enabled'] == true); + final presenceEnabled = _shouldEnablePresence(); final accessTokenPayload = {}; final config = { @@ -351,7 +369,7 @@ class RealtimeChannel { RealtimeChannel onPresenceSync( void Function(RealtimePresenceSyncPayload payload) callback, ) { - return onEvents( + final result = onEvents( 'presence', ChannelFilter( event: PresenceEvent.sync.name, @@ -361,6 +379,8 @@ class RealtimeChannel { Map.from(payload))); }, ); + _handlePresenceUpdate(); + return result; } /// Sets up a listener for realtime presence join event. @@ -377,7 +397,7 @@ class RealtimeChannel { RealtimeChannel onPresenceJoin( void Function(RealtimePresenceJoinPayload payload) callback, ) { - return onEvents( + final result = onEvents( 'presence', ChannelFilter( event: PresenceEvent.join.name, @@ -387,6 +407,8 @@ class RealtimeChannel { Map.from(payload))); }, ); + _handlePresenceUpdate(); + return result; } /// Sets up a listener for realtime presence leave event. @@ -403,7 +425,7 @@ class RealtimeChannel { RealtimeChannel onPresenceLeave( void Function(RealtimePresenceLeavePayload payload) callback, ) { - return onEvents( + final result = onEvents( 'presence', ChannelFilter( event: PresenceEvent.leave.name, @@ -413,6 +435,8 @@ class RealtimeChannel { Map.from(payload))); }, ); + _handlePresenceUpdate(); + return result; } /// Sets up a listener for realtime system events for debugging purposes. diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index c926baf89..94f14c816 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -480,4 +480,135 @@ void main() { expect(joinPayload['config']['presence']['enabled'], isTrue); }); }); + + group('presence resubscription', () { + setUp(() { + socket = RealtimeClient('', timeout: const Duration(milliseconds: 1234)); + }); + + test( + 'should resubscribe when presence callback added to subscribed channel without initial presence', + () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.subscribe(); + expect(channel.params['config']['presence']['enabled'], isFalse); + + channel.onPresenceSync((payload) {}); + + expect(channel.params['config']['presence']['enabled'], isTrue); + }); + + test( + 'should not resubscribe when presence callback added to channel with existing presence', + () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(enabled: true), + ); + + channel.subscribe(); + final initialPayload = Map.from(channel.params); + + channel.onPresenceSync((payload) {}); + + expect(channel.params['config']['presence']['enabled'], isTrue); + expect(channel.params, equals(initialPayload)); + }); + + test( + 'should only resubscribe once when multiple presence callbacks added', + () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.subscribe(); + expect(channel.params['config']['presence']['enabled'], isFalse); + + channel.onPresenceSync((payload) {}); + expect(channel.params['config']['presence']['enabled'], isTrue); + + final payloadAfterFirst = Map.from(channel.params); + + channel.onPresenceJoin((payload) {}); + channel.onPresenceLeave((payload) {}); + + expect(channel.params, equals(payloadAfterFirst)); + }); + + test( + 'should not resubscribe when presence callback added to unsubscribed channel', + () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + expect(channel.joinedOnce, isFalse); + + channel.onPresenceSync((payload) {}); + + expect(channel.params['config']['presence']['enabled'], isFalse); + }); + + test( + 'should receive presence events after resubscription triggered by adding callback', + () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.subscribe(); + + bool syncCalled = false; + channel.onPresenceSync((payload) { + syncCalled = true; + }); + + channel.trigger('presence', {'event': 'sync'}, '1'); + + expect(syncCalled, isTrue); + }); + + test('should handle presence join callback resubscription', () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.subscribe(); + expect(channel.params['config']['presence']['enabled'], isFalse); + + channel.onPresenceJoin((payload) {}); + + expect(channel.params['config']['presence']['enabled'], isTrue); + }); + + test('should handle presence leave callback resubscription', () { + channel = RealtimeChannel( + 'topic', + socket, + params: const RealtimeChannelConfig(), + ); + + channel.subscribe(); + expect(channel.params['config']['presence']['enabled'], isFalse); + + channel.onPresenceLeave((payload) {}); + + expect(channel.params['config']['presence']['enabled'], isTrue); + }); + }); } From 07606c7d206c106b37e480936056dbdcbc0cc172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Mon, 29 Sep 2025 16:18:18 +0100 Subject: [PATCH 6/7] fix test --- packages/realtime_client/lib/src/realtime_channel.dart | 2 ++ packages/realtime_client/test/channel_test.dart | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/packages/realtime_client/lib/src/realtime_channel.dart b/packages/realtime_client/lib/src/realtime_channel.dart index fdfd30960..8710a77fb 100644 --- a/packages/realtime_client/lib/src/realtime_channel.dart +++ b/packages/realtime_client/lib/src/realtime_channel.dart @@ -116,7 +116,9 @@ class RealtimeChannel { if (!currentPresenceEnabled && shouldEnablePresence) { final config = Map.from(params['config']); + config['presence'] = Map.from(config['presence']); config['presence']['enabled'] = true; + params['config'] = config; updateJoinPayload({'config': config}); rejoin(); } diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 94f14c816..8aa343a4c 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -496,6 +496,7 @@ void main() { ); channel.subscribe(); + channel.joinPush.trigger('ok', {}); expect(channel.params['config']['presence']['enabled'], isFalse); channel.onPresenceSync((payload) {}); @@ -513,6 +514,7 @@ void main() { ); channel.subscribe(); + channel.joinPush.trigger('ok', {}); final initialPayload = Map.from(channel.params); channel.onPresenceSync((payload) {}); @@ -531,6 +533,7 @@ void main() { ); channel.subscribe(); + channel.joinPush.trigger('ok', {}); expect(channel.params['config']['presence']['enabled'], isFalse); channel.onPresenceSync((payload) {}); @@ -570,6 +573,7 @@ void main() { ); channel.subscribe(); + channel.joinPush.trigger('ok', {}); bool syncCalled = false; channel.onPresenceSync((payload) { @@ -589,6 +593,7 @@ void main() { ); channel.subscribe(); + channel.joinPush.trigger('ok', {}); expect(channel.params['config']['presence']['enabled'], isFalse); channel.onPresenceJoin((payload) {}); @@ -604,6 +609,7 @@ void main() { ); channel.subscribe(); + channel.joinPush.trigger('ok', {}); expect(channel.params['config']['presence']['enabled'], isFalse); channel.onPresenceLeave((payload) {}); From 0085f6f408a15ff02486c28d06401b08af2c18a3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 29 Sep 2025 12:20:12 -0300 Subject: [PATCH 7/7] style: dart format --- packages/realtime_client/test/channel_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 8aa343a4c..8d8733d86 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -523,8 +523,7 @@ void main() { expect(channel.params, equals(initialPayload)); }); - test( - 'should only resubscribe once when multiple presence callbacks added', + test('should only resubscribe once when multiple presence callbacks added', () { channel = RealtimeChannel( 'topic',