diff --git a/.gitignore b/.gitignore index d858f416..93f84e0c 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,5 @@ iOSInjectionProject/ Secrets.swift lcov.info temp_coverage + +.cursor \ No newline at end of file diff --git a/Sources/Realtime/CallbackManager.swift b/Sources/Realtime/CallbackManager.swift index 91a8e199..e6c8d992 100644 --- a/Sources/Realtime/CallbackManager.swift +++ b/Sources/Realtime/CallbackManager.swift @@ -204,4 +204,12 @@ enum RealtimeCallback { case let .system(callback): callback.id } } + + var isPresence: Bool { + if case .presence = self { + return true + } else { + return false + } + } } diff --git a/Sources/Realtime/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift index d7447882..9b6efa14 100644 --- a/Sources/Realtime/RealtimeChannelV2.swift +++ b/Sources/Realtime/RealtimeChannelV2.swift @@ -35,7 +35,9 @@ public final class RealtimeChannelV2: Sendable { private var mutableState = MutableState() let topic: String - let config: RealtimeChannelConfig + + @MainActor var config: RealtimeChannelConfig + let logger: (any SupabaseLogger)? let socket: RealtimeClientV2 @@ -97,6 +99,8 @@ public final class RealtimeChannelV2: Sendable { status = .subscribing logger?.debug("Subscribing to channel \(topic)") + config.presence.enabled = callbackManager.callbacks.contains(where: { $0.isPresence }) + let joinConfig = RealtimeJoinConfig( broadcast: config.broadcast, presence: config.presence, @@ -168,6 +172,7 @@ public final class RealtimeChannelV2: Sendable { /// - Parameters: /// - event: Broadcast message event. /// - message: Message payload. + @MainActor public func broadcast(event: String, message: JSONObject) async { if status != .subscribed { struct Message: Encodable { @@ -374,7 +379,7 @@ public final class RealtimeChannelV2: Sendable { status = .unsubscribed case .error: - logger?.debug( + logger?.error( "Received an error in channel \(message.topic). That could be as a result of an invalid access token" ) @@ -396,7 +401,18 @@ public final class RealtimeChannelV2: Sendable { public func onPresenceChange( _ callback: @escaping @Sendable (any PresenceAction) -> Void ) -> RealtimeSubscription { + if status == .subscribed { + logger?.debug( + "Resubscribe to \(self.topic) due to change in presence callback on joined channel." + ) + Task { + await unsubscribe() + await subscribe() + } + } + let id = callbackManager.addPresenceCallback(callback: callback) + return RealtimeSubscription { [weak callbackManager, logger] in logger?.debug("Removing presence callback with id: \(id)") callbackManager?.removeCallback(id: id) diff --git a/Sources/Realtime/RealtimeJoinConfig.swift b/Sources/Realtime/RealtimeJoinConfig.swift index a617757f..3c5df1f3 100644 --- a/Sources/Realtime/RealtimeJoinConfig.swift +++ b/Sources/Realtime/RealtimeJoinConfig.swift @@ -50,6 +50,7 @@ public struct BroadcastJoinConfig: Codable, Hashable, Sendable { public struct PresenceJoinConfig: Codable, Hashable, Sendable { /// Track presence payload across clients. public var key: String = "" + var enabled: Bool = false } public enum PostgresChangeEvent: String, Codable, Sendable { diff --git a/Tests/RealtimeTests/RealtimeChannelTests.swift b/Tests/RealtimeTests/RealtimeChannelTests.swift index c213d2d6..8589519d 100644 --- a/Tests/RealtimeTests/RealtimeChannelTests.swift +++ b/Tests/RealtimeTests/RealtimeChannelTests.swift @@ -6,6 +6,7 @@ // import InlineSnapshotTesting +import TestHelpers import XCTest import XCTestDynamicOverlay @@ -128,4 +129,67 @@ final class RealtimeChannelTests: XCTestCase { """ } } + + @MainActor + func testPresenceEnabledDuringSubscribe() async { + // Create fake WebSocket for testing + let (client, server) = FakeWebSocket.fakes() + + let socket = RealtimeClientV2( + url: URL(string: "https://localhost:54321/realtime/v1")!, + options: RealtimeClientOptions( + headers: ["apikey": "test-key"], + accessToken: { "test-token" } + ), + wsTransport: { _, _ in client }, + http: HTTPClientMock() + ) + + // Create a channel without presence callback initially + let channel = socket.channel("test-topic") + + // Initially presence should be disabled + XCTAssertFalse(channel.config.presence.enabled) + + // Connect the socket + await socket.connect() + + // Add a presence callback before subscribing + let presenceSubscription = channel.onPresenceChange { _ in } + + // Verify that presence callback exists + XCTAssertTrue(channel.callbackManager.callbacks.contains(where: { $0.isPresence })) + + // Start subscription process + Task { + await channel.subscribe() + } + + // Wait for the join message to be sent + await Task.megaYield() + + // Check the sent events to verify presence enabled is set correctly + let joinEvents = server.receivedEvents.compactMap { $0.realtimeMessage }.filter { + $0.event == "phx_join" + } + + // Should have at least one join event + XCTAssertGreaterThan(joinEvents.count, 0) + + // Check that the presence enabled flag is set to true in the join payload + if let joinEvent = joinEvents.first, + let config = joinEvent.payload["config"]?.objectValue, + let presence = config["presence"]?.objectValue, + let enabled = presence["enabled"]?.boolValue + { + XCTAssertTrue(enabled, "Presence should be enabled when presence callback exists") + } else { + XCTFail("Could not find presence enabled flag in join payload") + } + + // Clean up + presenceSubscription.cancel() + await channel.unsubscribe() + socket.disconnect() + } } diff --git a/Tests/RealtimeTests/RealtimeTests.swift b/Tests/RealtimeTests/RealtimeTests.swift index 54d26be0..59cb9ff5 100644 --- a/Tests/RealtimeTests/RealtimeTests.swift +++ b/Tests/RealtimeTests/RealtimeTests.swift @@ -169,6 +169,7 @@ final class RealtimeTests: XCTestCase { } ], "presence" : { + "enabled" : false, "key" : "" }, "private" : false @@ -241,6 +242,7 @@ final class RealtimeTests: XCTestCase { ], "presence" : { + "enabled" : false, "key" : "" }, "private" : false @@ -264,6 +266,7 @@ final class RealtimeTests: XCTestCase { ], "presence" : { + "enabled" : false, "key" : "" }, "private" : false