Skip to content

Commit

Permalink
Spec complete for Presence in line with [1].
Browse files Browse the repository at this point in the history
  • Loading branch information
umair-ably committed Nov 15, 2024
1 parent 609ea6d commit e037276
Show file tree
Hide file tree
Showing 12 changed files with 430 additions and 67 deletions.
4 changes: 2 additions & 2 deletions AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"originHash" : "a296396707b7685153f4cf548f6281f483d562002fe11235f1fc3bb053be91d7",
"originHash" : "1ad2d7338668d15feccbf564582941161acd47349bfca8f34374e11c69677ae8",
"pins" : [
{
"identity" : "ably-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ably/ably-cocoa",
"state" : {
"branch" : "main",
"revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51"
"revision" : "f7bff4b1c941b4c7b952b9224a33674e2302e19f"
}
},
{
Expand Down
20 changes: 16 additions & 4 deletions Example/AblyChatExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ struct ContentView: View {

private func room() async throws -> Room {
let chosenChatClient: ChatClient = (mode == .mock) ? mockChatClient : liveChatClient
return try await chosenChatClient.rooms.get(roomID: roomID, options: .init(reactions: .init()))
return try await chosenChatClient.rooms.get(
roomID: roomID,
options: .init(
presence: .init(),
reactions: .init()
)
)
}

private var sendTitle: String {
Expand Down Expand Up @@ -127,10 +133,10 @@ struct ContentView: View {
.tryTask { try await attachRoom() }
.tryTask { try await showMessages() }
.tryTask { try await showReactions() }
.tryTask { try await showPresence() }
.tryTask {
// NOTE: As we implement more features, move them out of the `if mode == .mock` block and into the main block just above.
if mode == .mock {
try await showPresence()
try await showTypings()
try await showOccupancy()
try await showRoomStatus()
Expand Down Expand Up @@ -185,9 +191,15 @@ struct ContentView: View {
}

func showPresence() async throws {
for await event in try await room().presence.subscribe(events: [.enter, .leave]) {
try await room().presence.enter(data: .init(userCustomData: ["status": .string("📱 Online")]))

for await event in try await room().presence.subscribe(events: [.enter, .leave, .update]) {
withAnimation {
messages.insert(BasicListItem(id: UUID().uuidString, title: "System", text: event.clientID + " \(event.action.displayedText)"), at: 0)
let status = event.data?.userCustomData?["status"]?.value as? String
let clientPresenceChangeMessage = "\(event.clientID) \(event.action.displayedText)"
let presenceMessage = status != nil ? "\(clientPresenceChangeMessage) with status: \(status!)" : clientPresenceChangeMessage

messages.insert(BasicListItem(id: UUID().uuidString, title: "System", text: presenceMessage), at: 0)
}
}
}
Expand Down
37 changes: 8 additions & 29 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,19 +277,19 @@ actor MockPresence: Presence {
MockStrings.names.shuffled().map { name in
PresenceMember(
clientID: name,
data: ["foo": "bar"],
data: PresenceData(userCustomData: nil),
action: .present,
extras: nil,
updatedAt: Date()
)
}
}

func get(params _: PresenceQuery?) async throws -> [PresenceMember] {
func get(params _: PresenceQuery) async throws -> [PresenceMember] {
MockStrings.names.shuffled().map { name in
PresenceMember(
clientID: name,
data: ["foo": "bar"],
data: PresenceData(userCustomData: nil),
action: .present,
extras: nil,
updatedAt: Date()
Expand All @@ -301,20 +301,7 @@ actor MockPresence: Presence {
fatalError("Not yet implemented")
}

func enter() async throws {
for subscription in mockSubscriptions {
subscription.emit(
PresenceEvent(
action: .enter,
clientID: clientID,
timestamp: Date(),
data: nil
)
)
}
}

func enter(data: PresenceData) async throws {
func enter(data: PresenceData? = nil) async throws {
for subscription in mockSubscriptions {
subscription.emit(
PresenceEvent(
Expand All @@ -327,28 +314,20 @@ actor MockPresence: Presence {
}
}

func update() async throws {
fatalError("Not yet implemented")
}

func update(data _: PresenceData) async throws {
fatalError("Not yet implemented")
}

func leave() async throws {
func update(data: PresenceData? = nil) async throws {
for subscription in mockSubscriptions {
subscription.emit(
PresenceEvent(
action: .leave,
action: .update,
clientID: clientID,
timestamp: Date(),
data: nil
data: data
)
)
}
}

func leave(data: PresenceData) async throws {
func leave(data: PresenceData? = nil) async throws {
for subscription in mockSubscriptions {
subscription.emit(
PresenceEvent(
Expand Down
4 changes: 4 additions & 0 deletions Example/AblyChatExample/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
fatalError("Not implemented")
}

var presence: ARTRealtimePresenceProtocol {
fatalError("Not implemented")
}

var errorReason: ARTErrorInfo? {
fatalError("Not implemented")
}
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"originHash" : "f00ee2e8c80adfe8d72deb089738cdb967aeae43e71837f90d99fc602728fe45",
"originHash" : "b6d25f160b01b473629481d68d4fe734b3981fcd87079531f784c2ade3afdc4d",
"pins" : [
{
"identity" : "ably-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ably/ably-cocoa",
"state" : {
"branch" : "main",
"revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51"
"revision" : "f7bff4b1c941b4c7b952b9224a33674e2302e19f"
}
},
{
Expand Down
178 changes: 178 additions & 0 deletions Sources/AblyChat/DefaultPresence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import Ably

@MainActor
internal final class DefaultPresence: Presence, EmitsDiscontinuities {
private let featureChannel: FeatureChannel
private let roomID: String
private let clientID: String
private let logger: InternalLogger

internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger) {
self.roomID = roomID
self.featureChannel = featureChannel
self.clientID = clientID
self.logger = logger
}

internal nonisolated var channel: any RealtimeChannelProtocol {
featureChannel.channel
}

internal func get() async throws -> [PresenceMember] {
try await withCheckedThrowingContinuation { continuation in
channel.presence.get { [processPresenceGet] members, error in
Task {
try processPresenceGet(continuation, members, error)
}
}
}
}

internal func get(params: PresenceQuery) async throws -> [PresenceMember] {
try await withCheckedThrowingContinuation { continuation in
channel.presence.get(params.asARTRealtimePresenceQuery()) { [processPresenceGet] members, error in
Task {
try processPresenceGet(continuation, members, error)
}
}
}
}

internal func isUserPresent(clientID: String) async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
channel.presence.get(ARTRealtimePresenceQuery(clientId: clientID, connectionId: nil)) { members, error in
Task {
guard let members else {
throw error ?? ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text")
}
continuation.resume(returning: !members.isEmpty)
}
}
}
}

internal func enter(data: PresenceData? = nil) async throws {
channel.presence.enterClient(clientID, data: data?.asQueryItems()) { error in
Task {
if let error {
throw error
}
}
}
}

internal func update(data: PresenceData? = nil) async throws {
channel.presence.update(data?.asQueryItems()) { error in
Task {
if let error {
throw error
}
}
}
}

internal func leave(data: PresenceData? = nil) async throws {
channel.presence.leave(data?.asQueryItems()) { error in
Task {
if let error {
throw error
}
}
}
}

internal func subscribe(event: PresenceEventType) async -> Subscription<PresenceEvent> {
let subscription = Subscription<PresenceEvent>(bufferingPolicy: .unbounded)
channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe] message in
Task {
let presenceEvent = try processPresenceSubscribe(message, event)
subscription.emit(presenceEvent)
}
}
return subscription
}

internal func subscribe(events: [PresenceEventType]) async -> Subscription<PresenceEvent> {
let subscription = Subscription<PresenceEvent>(bufferingPolicy: .unbounded)
for event in events {
channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe] message in
Task {
let presenceEvent = try processPresenceSubscribe(message, event)
subscription.emit(presenceEvent)
}
}
}
return subscription
}

internal func subscribeToDiscontinuities() async -> Subscription<ARTErrorInfo> {
await featureChannel.subscribeToDiscontinuities()
}

private func decodePresenceData(from data: Any?) -> PresenceData? {
guard let userData = (data as? [String: Any]) else {
return nil
}

do {
let jsonData = try JSONSerialization.data(withJSONObject: userData, options: [])
let presenceData = try JSONDecoder().decode(PresenceData.self, from: jsonData)
return presenceData
} catch {
print("Failed to decode PresenceData: \(error)")
return nil
}
}

private func processPresenceGet(continuation: CheckedContinuation<[PresenceMember], any Error>, members: [ARTPresenceMessage]?, error: ARTErrorInfo?) throws {
guard let members else {
throw error ?? ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text")
}
let presenceMembers = try members.map { member in
guard let data = member.data as? [String: Any] else {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data")
}

guard let clientID = member.clientId else {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId")
}

guard let timestamp = member.timestamp else {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp")
}

let userCustomData = data["userCustomData"] as? PresenceData

// Seems like we want to just forward on `extras` from the cocoa SDK but that is an `ARTJsonCompatible` type which is not `Sendable`... currently just converting this to a `Sendable` type (`String`) until we know what to do with this.
let extras = member.extras?.toJSONString()

return PresenceMember(
clientID: clientID,
data: userCustomData ?? .init(),
action: PresenceMember.Action(from: member.action),
extras: extras,
updatedAt: timestamp
)
}
continuation.resume(returning: presenceMembers)
}

private func processPresenceSubscribe(_ message: ARTPresenceMessage, for event: PresenceEventType) throws -> PresenceEvent {
guard let clientID = message.clientId else {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId")
}

guard let timestamp = message.timestamp else {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp")
}

let userCustomDataDecoded = decodePresenceData(from: message.data)

return PresenceEvent(
action: event,
clientID: clientID,
timestamp: timestamp,
data: userCustomDataDecoded ?? .init()
)
}
}
15 changes: 11 additions & 4 deletions Sources/AblyChat/Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,21 @@ public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {}
internal extension RealtimeClientProtocol {
// Function to get the channel with merged options
func getChannel(_ name: String, opts: ARTRealtimeChannelOptions? = nil) -> any RealtimeChannelProtocol {
// Merge opts and defaultChannelOptions
let resolvedOptions = opts ?? ARTRealtimeChannelOptions()
// Create a new instance of ARTRealtimeChannelOptions if opts is nil
let resolvedOptions = ARTRealtimeChannelOptions()

// Merge params if available, using defaultChannelOptions as fallback
resolvedOptions.params = opts?.params?.merging(
// Merge params if available, using opts first, then defaultChannelOptions as fallback
resolvedOptions.params = (opts?.params ?? [:]).merging(
defaultChannelOptions.params ?? [:]
) { _, new in new }

// Apply other options from `opts` if necessary
if let customOpts = opts {
resolvedOptions.modes = customOpts.modes
resolvedOptions.cipher = customOpts.cipher
resolvedOptions.attachOnSubscribe = customOpts.attachOnSubscribe
}

// Return the resolved channel
return channels.get(name, options: resolvedOptions)
}
Expand Down
Loading

0 comments on commit e037276

Please sign in to comment.