Skip to content

Commit

Permalink
Improve API and internals for presence data
Browse files Browse the repository at this point in the history
Public API improvements:

- You can now pass any JSON value as presence data (previously, the
  top-level value had to be an object, and you could not pass arrays or
  nested objects). This brings us in line with CHA-PR2a.

- Do not expose the `userCustomData` property of the presence data
  object in the public API; it’s an implementation detail.

- Conform to the `ExpressibleBy*Literal` protocols, making it easier to
  create a JSON value.

I have deliberately chosen to make the data-arg variants of the presence
operation methods take a non-optional PresenceData. This is to minimise
confusion between the absence of presence data and a presence data with
JSON value `null`. This is how I wrote this API in 20e7f5f, but I didn’t
restore it properly in 4ee16bd.

The JSONValue type introduced here is based on the example given in [1].

Internals improvements:

- Fix the presence object that gets passed to ably-cocoa when the user
  specifies no presence data; we were previously passing an empty string
  as the presence data in this case. (See below for where I got the
  “correct” behaviour from.)

- Simplify the way in which we decode the presence data received from
  ably-cocoa (i.e. don’t do a round-trip of JSON serialization and
  deserialization); this comes at the cost of not getting a little bit for
  free from Swift’s serialization mechanism, but I think it’s worth it

The behaviour of how to map the chat presence data public API to the
object exchanged with the core SDK is not currently fully specified. So,
the behaviour that I’ve implemented here is based on the behaviour of
the JS Chat SDK at 69ea478. I’ve created spec issue [2] in order to
specify this stuff properly, but I’m in a slight rush to get this public
API fixed before we release our first beta, so I’ll address this later.

Resolves #178.

[1] https://www.douggregor.net/posts/swift-for-cxx-practitioners-literals/
[2] ably/specification#256
  • Loading branch information
lawrence-forooghian committed Dec 10, 2024
1 parent f11e0a1 commit 80e8585
Show file tree
Hide file tree
Showing 9 changed files with 486 additions and 132 deletions.
4 changes: 2 additions & 2 deletions Example/AblyChatExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,13 @@ struct ContentView: View {
}

func showPresence() async throws {
try await room().presence.enter(data: .init(userCustomData: ["status": .string("📱 Online")]))
try await room().presence.enter(data: ["status": "📱 Online"])

// Continue listening for new presence events on a background task so this function can return
Task {
for await event in try await room().presence.subscribe(events: [.enter, .leave, .update]) {
withAnimation {
let status = event.data?.userCustomData?["status"]?.value as? String
let status = event.data?.objectValue?["status"]?.stringValue
let clientPresenceChangeMessage = "\(event.clientID) \(event.action.displayedText)"
let presenceMessage = status != nil ? "\(clientPresenceChangeMessage) with status: \(status!)" : clientPresenceChangeMessage

Expand Down
36 changes: 30 additions & 6 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,40 +301,64 @@ actor MockPresence: Presence {
fatalError("Not yet implemented")
}

func enter(data: PresenceData? = nil) async throws {
func enter() async throws {
try await enter(dataForEvent: nil)
}

func enter(data: PresenceData) async throws {
try await enter(dataForEvent: data)
}

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

func update(data: PresenceData? = nil) async throws {
func update() async throws {
try await update(dataForEvent: nil)
}

func update(data: PresenceData) async throws {
try await update(dataForEvent: data)
}

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

func leave(data: PresenceData? = nil) async throws {
func leave() async throws {
try await leave(dataForEvent: nil)
}

func leave(data: PresenceData) async throws {
try await leave(dataForEvent: data)
}

func leave(dataForEvent: PresenceData? = nil) async throws {
for subscription in mockSubscriptions {
subscription.emit(
PresenceEvent(
action: .leave,
clientID: clientID,
timestamp: Date(),
data: data
data: dataForEvent
)
)
}
Expand Down
67 changes: 49 additions & 18 deletions Sources/AblyChat/DefaultPresence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,16 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
}
}

internal func enter(data: PresenceData) async throws {
try await enter(optionalData: data)
}

internal func enter() async throws {
try await enter(optionalData: nil)
}

// (CHA-PR3a) Users may choose to enter presence, optionally providing custom data to enter with. The overall presence data must retain the format specified in CHA-PR2.
internal func enter(data: PresenceData? = nil) async throws {
private func enter(optionalData data: PresenceData?) async throws {
logger.log(message: "Entering presence", level: .debug)

// CHA-PR3c to CHA-PR3g
Expand All @@ -103,8 +111,11 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
logger.log(message: "Error waiting to be able to perform presence enter operation: \(error)", level: .error)
throw error
}

let dto = PresenceDataDTO(userCustomData: data)

return try await withCheckedThrowingContinuation { continuation in
channel.presence.enterClient(clientID, data: data?.asJSONObject()) { [logger] error in
channel.presence.enterClient(clientID, data: JSONValue.object(dto.toJSONObjectValue).toAblyCocoaPresenceData) { [logger] error in
if let error {
logger.log(message: "Error entering presence: \(error)", level: .error)
continuation.resume(throwing: error)
Expand All @@ -115,8 +126,16 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
}
}

internal func update(data: PresenceData) async throws {
try await update(optionalData: data)
}

internal func update() async throws {
try await update(optionalData: nil)
}

// (CHA-PR10a) Users may choose to update their presence data, optionally providing custom data to update with. The overall presence data must retain the format specified in CHA-PR2.
internal func update(data: PresenceData? = nil) async throws {
private func update(optionalData data: PresenceData?) async throws {
logger.log(message: "Updating presence", level: .debug)

// CHA-PR10c to CHA-PR10g
Expand All @@ -127,8 +146,10 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
throw error
}

let dto = PresenceDataDTO(userCustomData: data)

return try await withCheckedThrowingContinuation { continuation in
channel.presence.update(data?.asJSONObject()) { [logger] error in
channel.presence.update(JSONValue.object(dto.toJSONObjectValue).toAblyCocoaPresenceData) { [logger] error in
if let error {
logger.log(message: "Error updating presence: \(error)", level: .error)
continuation.resume(throwing: error)
Expand All @@ -139,8 +160,16 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
}
}

internal func leave(data: PresenceData) async throws {
try await leave(optionalData: data)
}

internal func leave() async throws {
try await leave(optionalData: nil)
}

// (CHA-PR4a) Users may choose to leave presence, which results in them being removed from the Realtime presence set.
internal func leave(data: PresenceData? = nil) async throws {
internal func leave(optionalData data: PresenceData?) async throws {
logger.log(message: "Leaving presence", level: .debug)

// CHA-PR6b to CHA-PR6f
Expand All @@ -150,8 +179,11 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
logger.log(message: "Error waiting to be able to perform presence leave operation: \(error)", level: .error)
throw error
}

let dto = PresenceDataDTO(userCustomData: data)

return try await withCheckedThrowingContinuation { continuation in
channel.presence.leave(data?.asJSONObject()) { [logger] error in
channel.presence.leave(JSONValue.object(dto.toJSONObjectValue).toAblyCocoaPresenceData) { [logger] error in
if let error {
logger.log(message: "Error leaving presence: \(error)", level: .error)
continuation.resume(throwing: error)
Expand Down Expand Up @@ -198,21 +230,20 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
await featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy)
}

private func decodePresenceData(from data: Any?) throws -> PresenceData? {
guard let data = data as? [String: Any] else {
private func decodePresenceDataDTO(from ablyCocoaPresenceData: Any?) throws -> PresenceDataDTO {
guard let ablyCocoaPresenceData else {
let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data")
logger.log(message: error.message, level: .error)
throw error
}

let jsonValue = JSONValue(ablyCocoaPresenceData: ablyCocoaPresenceData)

do {
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
let presenceData = try JSONDecoder().decode(PresenceData.self, from: jsonData)
return presenceData
return try PresenceDataDTO(jsonValue: jsonValue)
} catch {
print("Failed to decode PresenceData: \(error)")
logger.log(message: "Failed to decode PresenceData: \(error)", level: .error)
return nil
logger.log(message: "Failed to decode presence data DTO from \(jsonValue), error \(error)", level: .error)
throw error
}
}

Expand All @@ -223,7 +254,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
throw error
}
let presenceMembers = try members.map { member in
let userCustomData = try decodePresenceData(from: member.data)
let presenceDataDTO = try decodePresenceDataDTO(from: member.data)

guard let clientID = member.clientId else {
let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId")
Expand All @@ -242,7 +273,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {

let presenceMember = PresenceMember(
clientID: clientID,
data: userCustomData ?? .init(),
data: presenceDataDTO.userCustomData,
action: PresenceMember.Action(from: member.action),
extras: extras,
updatedAt: timestamp
Expand All @@ -267,13 +298,13 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities {
throw error
}

let userCustomDataDecoded = try decodePresenceData(from: message.data)
let presenceDataDTO = try decodePresenceDataDTO(from: message.data)

let presenceEvent = PresenceEvent(
action: event,
clientID: clientID,
timestamp: timestamp,
data: userCustomDataDecoded ?? .init()
data: presenceDataDTO.userCustomData
)

logger.log(message: "Returning presence event: \(presenceEvent)", level: .debug)
Expand Down
Loading

0 comments on commit 80e8585

Please sign in to comment.