Skip to content

Commit

Permalink
Spec complete for Ephemeral Room Reactions in line with [1].
Browse files Browse the repository at this point in the history
`CHA-ER3b` & `CHA-ER3c` have not been implemented despite being outlined in [1], as per the ADR at [2].
Example app has also been updated to support both a working and mock implementation of the Chat app.

[1] - https://sdk.ably.com/builds/ably/specification/pull/200/chat-features/
[2] - https://ably.atlassian.net/wiki/spaces/CHA/pages/3438116905/CHADR-066+Removing+Reserved+Keyspace#Solution
  • Loading branch information
umair-ably committed Oct 18, 2024
1 parent 27354a8 commit 7ed89eb
Show file tree
Hide file tree
Showing 12 changed files with 246 additions and 17 deletions.
64 changes: 54 additions & 10 deletions Example/AblyChatExample/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Ably
import AblyChat
import SwiftUI

Expand All @@ -11,11 +12,24 @@ struct ContentView: View {
let screenHeight = UIScreen.main.bounds.height
#endif

@State private var chatClient = MockChatClient(
// Can be replaced with your own room ID
private let roomID = "DemoRoomID"

// Set mode to `.live` if you wish to connect to actual instances of the Chat client in either Prod or Sandbox environments. Setting the mode to `.mock` will use the `MockChatClient`, and therefore simulate all features of the Chat app.
private let mode = Environment.mock
private enum Environment {
case mock
case live
}

@State private var mockChatClient = MockChatClient(
realtime: MockRealtime.create(),
clientOptions: ClientOptions()
)

private let liveRealtime: ARTRealtime
@State private var liveChatClient: DefaultChatClient

@State private var title = "Room"
@State private var messages = [BasicListItem]()
@State private var reactions: [Reaction] = []
Expand All @@ -24,16 +38,27 @@ struct ContentView: View {
@State private var occupancyInfo = "Connections: 0"
@State private var statusInfo = ""

// You only need to set `options.key` and `options.clientId` if your mode is set to `.live`. Otherwise, you can ignore this.
init() {
let options = ARTClientOptions()
options.key = ""
options.clientId = ""
liveRealtime = ARTRealtime(options: options)

_liveChatClient = State(initialValue: DefaultChatClient(realtime: liveRealtime, clientOptions: .init()))
}

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

private var sendTitle: String {
newMessage.isEmpty ? ReactionType.like.emoji : "Send"
}

var body: some View {
ZStack {
let zStack = ZStack {
VStack {
Text(title)
.font(.headline)
Expand Down Expand Up @@ -99,18 +124,23 @@ struct ContentView: View {
}
}
.tryTask { try await setDefaultTitle() }
.tryTask { try await attachRoom() }
.tryTask { try await showMessages() }
.tryTask { try await showReactions() }
.tryTask { try await showPresence() }
.tryTask { try await showTypings() }
.tryTask { try await showOccupancy() }
.tryTask { try await showRoomStatus() }
if mode == .mock {
zStack.tryTask { try await showPresence() }
.tryTask { try await showTypings() }
.tryTask { try await showOccupancy() }
.tryTask { try await showRoomStatus() }
}

// NOTE: As we implement more features, move them out of the `if mode == .mock` block and into the main `zStack` block just above.
}

func sendButtonAction() {
if newMessage.isEmpty {
Task {
try await sendReaction(type: ReactionType.like.rawValue)
try await sendReaction(type: ReactionType.like.emoji)
}
} else {
Task {
Expand All @@ -123,16 +153,30 @@ struct ContentView: View {
title = try await "\(room().roomID)"
}

func attachRoom() async throws {
try await room().attach()
}

func showMessages() async throws {
for await message in try await room().messages.subscribe(bufferingPolicy: .unbounded) {
let messagesSubscription = try await room().messages.subscribe(bufferingPolicy: .unbounded)
let previousMessages = try await messagesSubscription.getPreviousMessages(params: .init())

for message in previousMessages.items {
withAnimation {
messages.append(BasicListItem(id: message.timeserial, title: message.clientID, text: message.text))
}
}

for await message in messagesSubscription {
withAnimation {
messages.insert(BasicListItem(id: message.timeserial, title: message.clientID, text: message.text), at: 0)
}
}
}

func showReactions() async throws {
for await reaction in try await room().reactions.subscribe(bufferingPolicy: .unbounded) {
let reactionSubscription = try await room().reactions.subscribe(bufferingPolicy: .unbounded)
for await reaction in reactionSubscription {
withAnimation {
showReaction(reaction.displayedText)
}
Expand Down
2 changes: 1 addition & 1 deletion Example/AblyChatExample/Mocks/Misc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,6 @@ enum ReactionType: String, CaseIterable {

extension Reaction {
var displayedText: String {
ReactionType(rawValue: type)?.emoji ?? ReactionType.idk.emoji
type
}
}
4 changes: 2 additions & 2 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ actor MockRoom: Room {
nonisolated lazy var status: any RoomStatus = MockRoomStatus(clientID: clientID, roomID: roomID)

func attach() async throws {
fatalError("Not yet implemented")
print("Mock client attached to room with roomID: \(roomID)")
}

func detach() async throws {
Expand Down Expand Up @@ -151,7 +151,7 @@ actor MockRoomReactions: RoomReactions {
private func createSubscription() -> MockSubscription<Reaction> {
let subscription = MockSubscription<Reaction>(randomElement: {
Reaction(
type: ReactionType.allCases.randomElement()!.rawValue,
type: ReactionType.allCases.randomElement()!.emoji,
metadata: [:],
headers: [:],
createdAt: Date(),
Expand Down
2 changes: 1 addition & 1 deletion Sources/AblyChat/DefaultMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {
}

let metadata = data["metadata"] as? Metadata
let headers = try message.extras?.toJSON()["headers"] as? Headers
let headers = extras["headers"] as? Headers

let message = Message(
timeserial: timeserial,
Expand Down
86 changes: 86 additions & 0 deletions Sources/AblyChat/DefaultRoomReactions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Ably

// TODO: This class errors with "Task-isolated value of type '() async throws -> ()' passed as a strongly transferred parameter; later accesses could race". Adding @MainActor fixes this, revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/83
@MainActor
internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities {
private let roomID: String
public let channel: RealtimeChannelProtocol
private let realtime: any RealtimeClientProtocol
private let logger: InternalLogger

internal init(realtime: any RealtimeClientProtocol, roomID: String, logger: InternalLogger) {
self.roomID = roomID
self.realtime = realtime
self.logger = logger

// (CHA-ER1) Reactions for a Room are sent on a corresponding realtime channel <roomId>::$chat::$reactions. For example, if your room id is my-room then the reactions channel will be my-room::$chat::$reactions.
let reactionsChannelName = "\(roomID)::$chat::$reactions"
channel = realtime.getChannel(reactionsChannelName)
}

// (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method.
// (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format.
internal func send(params: SendReactionParams) async throws {
let extras: NSDictionary = ["headers": params.headers ?? [:]]
channel.publish(RoomReactionEvents.reaction.rawValue, data: params.asQueryItems(), extras: extras)
}

// (CHA-ER4) A user may subscribe to reaction events in Realtime.
// (CHA-ER4a) A user may provide a listener to subscribe to reaction events. This operation must have no side-effects in relation to room or underlying status. When a realtime message with name roomReaction is received, this message is converted into a reaction object and emitted to subscribers.
internal func subscribe(bufferingPolicy: BufferingPolicy) async -> Subscription<Reaction> {
let subscription = Subscription<Reaction>(bufferingPolicy: bufferingPolicy)

// (CHA-ER4c) Realtime events with an unknown name shall be silently discarded.
channel.subscribe(RoomReactionEvents.reaction.rawValue) { [realtime, logger] message in
Task {
do {
guard let data = message.data as? [String: Any],
let reactionType = data["type"] as? String
else {
throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text")
}

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")
}

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

let metadata = data["metadata"] as? Metadata
let headers = extras["headers"] as? Headers

// (CHA-ER4d) Realtime events that are malformed (unknown fields should be ignored) shall not be emitted to listeners.
let reaction = Reaction(
type: reactionType,
metadata: metadata ?? .init(),
headers: headers ?? .init(),
createdAt: timestamp,
clientID: clientID,
isSelf: message.clientId == realtime.clientId
)

subscription.emit(reaction)
} catch {
logger.log(message: "Error processing incoming reaction message: \(error)", level: .error)
}
}
}

return subscription
}

// TODO: (CHA-ER5) Users may subscribe to discontinuity events to know when there’s been a break in reactions that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle. https://github.com/ably-labs/ably-chat-swift/issues/47
internal func subscribeToDiscontinuities() async -> Subscription<ARTErrorInfo> {
fatalError("Not implemented")
}

private enum RoomReactionsError: Error {
case noReferenceToSelf
}
}
4 changes: 4 additions & 0 deletions Sources/AblyChat/Events.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
internal enum MessageEvent: String {
case created = "message.created"
}

internal enum RoomReactionEvents: String {
case reaction = "roomReaction"
}
1 change: 1 addition & 0 deletions Sources/AblyChat/Reaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
public typealias ReactionHeaders = Headers
public typealias ReactionMetadata = Metadata

// (CHA-ER2) A Reaction corresponds to a single reaction in a chat room. This is analogous to a single user-specified message on an Ably channel (NOTE: not a ProtocolMessage).
public struct Reaction: Sendable {
public var type: String
public var metadata: ReactionMetadata
Expand Down
13 changes: 12 additions & 1 deletion Sources/AblyChat/Room.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal actor DefaultRoom: Room {
private let chatAPI: ChatAPI

public nonisolated let messages: any Messages
private let _reactions: (any RoomReactions)?

// Exposed for testing.
private nonisolated let realtime: RealtimeClient
Expand Down Expand Up @@ -53,14 +54,24 @@ internal actor DefaultRoom: Room {
roomID: roomID,
clientID: clientId
)

_reactions = options.reactions != nil ? await DefaultRoomReactions(
realtime: realtime,
roomID: roomID,
logger: logger
) : nil
}

public nonisolated var presence: any Presence {
fatalError("Not yet implemented")
}

public nonisolated var reactions: any RoomReactions {
fatalError("Not yet implemented")
guard let _reactions else {
fatalError("Reactions are not enabled for this room")
}

return _reactions
}

public nonisolated var typing: any Typing {
Expand Down
10 changes: 10 additions & 0 deletions Sources/AblyChat/RoomReactions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ public struct SendReactionParams: Sendable {
self.headers = headers
}
}

internal extension SendReactionParams {
// Same as `ARTDataQuery.asQueryItems` from ably-cocoa.
func asQueryItems() -> [String: String] {
var dict: [String: String] = [:]
dict["type"] = "\(type)"
dict["metadata"] = "\(metadata ?? [:])"
return dict
}
}
10 changes: 10 additions & 0 deletions Sources/AblyChat/Subscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ public struct Subscription<Element: Sendable>: Sendable, AsyncSequence {
}
}

// TODO: https://github.com/ably-labs/ably-chat-swift/issues/36 Revisit how we want to unsubscribe to fulfil CHA-M4b & CHA-ER4b. I think exposing this publicly for all Subscription types is suitable.
public func finish() {
switch mode {
case let .default(_, continuation):
continuation.finish()
case .mockAsyncSequence:
fatalError("`finish` cannot be called on a Subscription that was created using init(mockAsyncSequence:)")
}
}

public struct AsyncIterator: AsyncIteratorProtocol {
fileprivate enum Mode {
case `default`(iterator: AsyncStream<Element>.AsyncIterator)
Expand Down
56 changes: 56 additions & 0 deletions Tests/AblyChatTests/DefaultRoomReactionsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
@testable import AblyChat
import Testing

struct DefaultRoomReactionsTests {
// @spec CHA-ER1
@Test
func init_channelNameIsSetAsReactionsChannelName() async throws {
// Given
let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$reactions")]))

// When
let defaultRoomReactions = await DefaultRoomReactions(realtime: realtime, roomID: "basketball")

// Then
await #expect(defaultRoomReactions.channel.name == "basketball::$chat::$reactions")
}

// @spec CHA-ER3a
@Test
func reactionsAreSentInTheCorrectFormat() async throws {
// channel name and roomID values are arbitrary
// Given
let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions")
let realtime = MockRealtime.create(channels: .init(channels: [channel]))
let defaultRoomReactions = await DefaultRoomReactions(realtime: realtime, roomID: "basketball")

let sendReactionParams = SendReactionParams(
type: "like",
metadata: ["test": MetadataValue.string("test")],
headers: ["test": HeadersValue.string("test")]
)

// When
try await defaultRoomReactions.send(params: sendReactionParams)

// Then
#expect(channel.lastMessagePublishedName == RoomReactionEvents.reaction.rawValue)
#expect(channel.lastMessagePublishedData as? [String: String] == sendReactionParams.asQueryItems())
#expect(channel.lastMessagePublishedExtras as? Dictionary == ["headers": sendReactionParams.headers])
}

// @spec CHA-ER4
@Test
func subscribe_returnsSubscription() async throws {
// all setup values here are arbitrary
// Given
let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$reactions")]))
let defaultRoomReactions = await DefaultRoomReactions(realtime: realtime, roomID: "basketball")

// When
let subscription: Subscription<Reaction>? = await defaultRoomReactions.subscribe(bufferingPolicy: .unbounded)

// Then
#expect(subscription != nil)
}
}
Loading

0 comments on commit 7ed89eb

Please sign in to comment.