Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into uts-chat-adapter
Browse files Browse the repository at this point in the history
  • Loading branch information
maratal committed Nov 17, 2024
2 parents 262384f + 609ea6d commit fe1e901
Show file tree
Hide file tree
Showing 32 changed files with 960 additions and 305 deletions.
63 changes: 54 additions & 9 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,8 +38,19 @@ 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 {
Expand Down Expand Up @@ -99,18 +124,24 @@ 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() }
.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()
}
}
}

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 +154,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 @@ -69,7 +69,7 @@ actor MockRoom: Room {
private var mockSubscriptions: [MockSubscription<RoomStatusChange>] = []

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 @@ -165,7 +165,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
12 changes: 0 additions & 12 deletions Sources/AblyChat/ChatAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,10 @@ internal final class ChatAPI: Sendable {
// (CHA-M3b) A message may be sent without metadata or headers. When these are not specified by the user, they must be omitted from the REST payload.
if let metadata = params.metadata {
body["metadata"] = metadata

// (CHA-M3c) metadata must not contain the key ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001.
if metadata.contains(where: { $0.key == "ably-chat" }) {
throw ARTErrorInfo.create(withCode: 40001, message: "metadata must not contain the key `ably-chat`")
}
}

if let headers = params.headers {
body["headers"] = headers

// (CHA-M3d) headers must not contain a key prefixed with ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001.
if headers.keys.contains(where: { keyString in
keyString.hasPrefix("ably-chat")
}) {
throw ARTErrorInfo.create(withCode: 40001, message: "headers must not contain any key with a prefix of `ably-chat`")
}
}

let response: SendMessageResponse = try await makeRequest(endpoint, method: "POST", body: body)
Expand Down
3 changes: 2 additions & 1 deletion Sources/AblyChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public actor DefaultChatClient: ChatClient {
self.realtime = realtime
self.clientOptions = clientOptions ?? .init()
logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel)
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger)
let roomFactory = DefaultRoomFactory()
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, roomFactory: roomFactory)
}

public nonisolated var connection: any Connection {
Expand Down
30 changes: 20 additions & 10 deletions Sources/AblyChat/DefaultMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ private struct MessageSubscriptionWrapper {
@MainActor
internal final class DefaultMessages: Messages, EmitsDiscontinuities {
private let roomID: String
public nonisolated let channel: RealtimeChannelProtocol
public nonisolated let featureChannel: FeatureChannel
private let chatAPI: ChatAPI
private let clientID: String

// TODO: https://github.com/ably-labs/ably-chat-swift/issues/36 - Handle unsubscribing in line with CHA-M4b
// UUID acts as a unique identifier for each listener/subscription. MessageSubscriptionWrapper houses the subscription and the timeserial of when it was attached or resumed.
private var subscriptionPoints: [UUID: MessageSubscriptionWrapper] = [:]

internal nonisolated init(channel: RealtimeChannelProtocol, chatAPI: ChatAPI, roomID: String, clientID: String) async {
self.channel = channel
internal nonisolated init(featureChannel: FeatureChannel, chatAPI: ChatAPI, roomID: String, clientID: String) async {
self.featureChannel = featureChannel
self.chatAPI = chatAPI
self.roomID = roomID
self.clientID = clientID
Expand All @@ -32,6 +32,10 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {
await handleChannelEvents(roomId: roomID)
}

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

// (CHA-M4) Messages can be received via a subscription in realtime.
internal func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription {
let uuid = UUID()
Expand Down Expand Up @@ -71,7 +75,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 Expand Up @@ -99,9 +103,9 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {
try await chatAPI.sendMessage(roomId: roomID, params: params)
}

// TODO: (CHA-M7) Users may subscribe to discontinuity events to know when there’s been a break in messages 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 nonisolated func subscribeToDiscontinuities() -> Subscription<ARTErrorInfo> {
fatalError("not implemented")
// (CHA-M7) Users may subscribe to discontinuity events to know when there’s been a break in messages that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle.
internal func subscribeToDiscontinuities() async -> Subscription<ARTErrorInfo> {
await featureChannel.subscribeToDiscontinuities()
}

private func getBeforeSubscriptionStart(_ uuid: UUID, params: QueryOptions) async throws -> any PaginatedResult<Message> {
Expand Down Expand Up @@ -205,27 +209,33 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {

// (CHA-M5b) If a subscription is added when the underlying realtime channel is in any other state, then its subscription point becomes the attachSerial at the the point of channel attachment.
return try await withCheckedThrowingContinuation { continuation in
// avoids multiple invocations of the continuation
var nillableContinuation: CheckedContinuation<TimeserialString, any Error>? = continuation

channel.on { [weak self] stateChange in
guard let self else {
return
}

switch stateChange.current {
case .attached:
// Handle successful attachment
if let attachSerial = channel.properties.attachSerial {
continuation.resume(returning: attachSerial)
nillableContinuation?.resume(returning: attachSerial)
} else {
continuation.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined"))
nillableContinuation?.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined"))
}
nillableContinuation = nil
case .failed, .suspended:
// TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32
continuation.resume(
nillableContinuation?.resume(
throwing: ARTErrorInfo.create(
withCode: ErrorCode.messagesAttachmentFailed.rawValue,
status: ErrorCode.messagesAttachmentFailed.statusCode,
message: "Channel failed to attach"
)
)
nillableContinuation = nil
default:
break
}
Expand Down
58 changes: 58 additions & 0 deletions Sources/AblyChat/DefaultRoomLifecycleContributor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Ably

internal actor DefaultRoomLifecycleContributor: RoomLifecycleContributor, EmitsDiscontinuities {
internal let channel: DefaultRoomLifecycleContributorChannel
internal let feature: RoomFeature
private var discontinuitySubscriptions: [Subscription<ARTErrorInfo>] = []

internal init(channel: DefaultRoomLifecycleContributorChannel, feature: RoomFeature) {
self.channel = channel
self.feature = feature
}

// MARK: - Discontinuities

internal func emitDiscontinuity(_ error: ARTErrorInfo) {
for subscription in discontinuitySubscriptions {
subscription.emit(error)
}
}

internal func subscribeToDiscontinuities() -> Subscription<ARTErrorInfo> {
let subscription = Subscription<ARTErrorInfo>(bufferingPolicy: .unbounded)
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
discontinuitySubscriptions.append(subscription)
return subscription
}
}

internal final class DefaultRoomLifecycleContributorChannel: RoomLifecycleContributorChannel {
private let underlyingChannel: any RealtimeChannelProtocol

internal init(underlyingChannel: any RealtimeChannelProtocol) {
self.underlyingChannel = underlyingChannel
}

internal func attach() async throws(ARTErrorInfo) {
try await underlyingChannel.attachAsync()
}

internal func detach() async throws(ARTErrorInfo) {
try await underlyingChannel.detachAsync()
}

internal var state: ARTRealtimeChannelState {
underlyingChannel.state
}

internal var errorReason: ARTErrorInfo? {
underlyingChannel.errorReason
}

internal func subscribeToState() async -> Subscription<ARTChannelStateChange> {
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
let subscription = Subscription<ARTChannelStateChange>(bufferingPolicy: .unbounded)
underlyingChannel.on { subscription.emit($0) }
return subscription
}
}
Loading

0 comments on commit fe1e901

Please sign in to comment.