-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Spec complete for sending and receiving messages
- Loading branch information
Showing
33 changed files
with
2,164 additions
and
111 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
import Ably | ||
import AblyChat | ||
import SwiftUI | ||
|
||
// TODO: This entire file can be removed and replaced with the actual example app we're going with. Leaving it here as a reference to something that is currently working. | ||
|
||
let clientId = "" // Set any string as a ClientID here e.g. "John" | ||
let apiKey = "" // Set your Ably API Key here | ||
|
||
struct MessageCell: View { | ||
var contentMessage: String | ||
var isCurrentUser: Bool | ||
|
||
var body: some View { | ||
Text(contentMessage) | ||
.padding(12) | ||
.foregroundColor(isCurrentUser ? Color.white : Color.black) | ||
.background(isCurrentUser ? Color.blue : Color.gray) | ||
.cornerRadius(12) | ||
} | ||
} | ||
|
||
struct MessageView: View { | ||
var currentMessage: Message | ||
|
||
var body: some View { | ||
HStack(alignment: .bottom) { | ||
if currentMessage.clientID == clientId { | ||
Spacer() | ||
} else {} | ||
MessageCell( | ||
contentMessage: currentMessage.text, | ||
isCurrentUser: currentMessage.clientID == clientId | ||
) | ||
} | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
.padding(.horizontal, 16) | ||
.padding(.vertical, 4) | ||
} | ||
} | ||
|
||
struct MessageDemoView: View { | ||
@State private var messages: [Message] = [] // Store the chat messages | ||
@State private var newMessage: String = "" // Store the message user is typing | ||
@State private var room: Room? // Keep track of the chat room | ||
|
||
var clientOptions: ARTClientOptions { | ||
let options = ARTClientOptions() | ||
options.clientId = clientId | ||
options.key = apiKey | ||
return options | ||
} | ||
|
||
var body: some View { | ||
VStack { | ||
ScrollViewReader { proxy in | ||
ScrollView { | ||
LazyVStack(spacing: 0) { | ||
ForEach(messages, id: \.self) { message in | ||
MessageView(currentMessage: message) | ||
.id(message) | ||
} | ||
} | ||
.onChange(of: messages.count) { | ||
withAnimation { | ||
proxy.scrollTo(messages.last, anchor: .bottom) | ||
} | ||
} | ||
.onAppear { | ||
withAnimation { | ||
proxy.scrollTo(messages.last, anchor: .bottom) | ||
} | ||
} | ||
} | ||
|
||
// send new message | ||
HStack { | ||
TextField("Send a message", text: $newMessage) | ||
#if !os(tvOS) | ||
.textFieldStyle(.roundedBorder) | ||
#endif | ||
Button(action: sendMessage) { | ||
Image(systemName: "paperplane") | ||
} | ||
} | ||
.padding() | ||
} | ||
.task { | ||
await startChat() | ||
} | ||
} | ||
} | ||
|
||
func startChat() async { | ||
let realtime = ARTRealtime(options: clientOptions) | ||
|
||
let chatClient = DefaultChatClient( | ||
realtime: realtime, | ||
clientOptions: nil | ||
) | ||
|
||
do { | ||
// Get the chat room | ||
room = try await chatClient.rooms.get(roomID: "umairsDemoRoom1", options: .init()) | ||
|
||
// attach to room | ||
try await room?.attach() | ||
|
||
// subscribe to messages | ||
let subscription = try await room?.messages.subscribe(bufferingPolicy: .unbounded) | ||
|
||
// use subscription to get previous messages | ||
let prevMessages = try await subscription?.getPreviousMessages(params: .init(orderBy: .oldestFirst)) | ||
|
||
// init local messages array with previous messages | ||
messages = .init(prevMessages?.items ?? []) | ||
|
||
// append new messages to local messages array as they are emitted | ||
if let subscription { | ||
for await message in subscription { | ||
messages.append(message) | ||
} | ||
} | ||
} catch { | ||
print("Error starting chat: \(error)") | ||
} | ||
} | ||
|
||
func sendMessage() { | ||
guard !newMessage.isEmpty else { | ||
return | ||
} | ||
Task { | ||
do { | ||
_ = try await room?.messages.send(params: .init(text: newMessage)) | ||
|
||
// Clear the text field after sending | ||
newMessage = "" | ||
} catch { | ||
print("Error sending message: \(error)") | ||
} | ||
} | ||
} | ||
} | ||
|
||
#Preview { | ||
MessageDemoView() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
// swift-tools-version: 6.0 | ||
|
||
import PackageDescription | ||
|
||
let package = Package( | ||
name: "AblyChat", | ||
platforms: [ | ||
.macOS(.v12), | ||
.iOS(.v14), | ||
.tvOS(.v14), | ||
], | ||
products: [ | ||
.library( | ||
name: "AblyChat", | ||
targets: [ | ||
"AblyChat", | ||
] | ||
), | ||
], | ||
dependencies: [ | ||
.package( | ||
url: "https://github.com/ably/ably-cocoa", | ||
branch: "main" | ||
), | ||
.package( | ||
url: "https://github.com/apple/swift-argument-parser", | ||
from: "1.5.0" | ||
), | ||
.package( | ||
url: "https://github.com/apple/swift-async-algorithms", | ||
from: "1.0.1" | ||
), | ||
], | ||
targets: [ | ||
.target( | ||
name: "AblyChat", | ||
dependencies: [ | ||
.product( | ||
name: "Ably", | ||
package: "ably-cocoa" | ||
), | ||
] | ||
), | ||
.testTarget( | ||
name: "AblyChatTests", | ||
dependencies: [ | ||
"AblyChat", | ||
.product( | ||
name: "AsyncAlgorithms", | ||
package: "swift-async-algorithms" | ||
), | ||
] | ||
), | ||
.executableTarget( | ||
name: "BuildTool", | ||
dependencies: [ | ||
.product( | ||
name: "ArgumentParser", | ||
package: "swift-argument-parser" | ||
), | ||
.product( | ||
name: "AsyncAlgorithms", | ||
package: "swift-async-algorithms" | ||
), | ||
] | ||
), | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
import Ably | ||
|
||
internal final class ChatAPI: Sendable { | ||
private let realtime: RealtimeClient | ||
private let apiVersion = "/chat/v1" | ||
|
||
public init(realtime: RealtimeClient) { | ||
self.realtime = realtime | ||
} | ||
|
||
internal func getChannel(_ name: String) -> any RealtimeChannelProtocol { | ||
realtime.getChannel(name) | ||
} | ||
|
||
// (CHA-M6) Messages should be queryable from a paginated REST API. | ||
internal func getMessages(roomId: String, params: QueryOptions) async throws -> any PaginatedResult<Message> { | ||
let endpoint = "\(apiVersion)/rooms/\(roomId)/messages" | ||
return try await makePaginatedRequest(endpoint, params: params.asQueryItems()) | ||
} | ||
|
||
internal struct SendMessageResponse: Codable { | ||
internal let timeserial: String | ||
internal let createdAt: Int64 | ||
} | ||
|
||
// (CHA-M3) Messages are sent to Ably via the Chat REST API, using the send method. | ||
// (CHA-M3a) When a message is sent successfully, the caller shall receive a struct representing the Message in response (as if it were received via Realtime event). | ||
internal func sendMessage(roomId: String, params: SendMessageParams) async throws -> Message { | ||
guard let clientId = realtime.clientId else { | ||
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") | ||
} | ||
|
||
let endpoint = "\(apiVersion)/rooms/\(roomId)/messages" | ||
var body: [String: Any] = ["text": params.text] | ||
|
||
// (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) | ||
|
||
// response.createdAt is in milliseconds, convert it to seconds | ||
let createdAtInSeconds = TimeInterval(Double(response.createdAt) / 1000) | ||
|
||
let message = Message( | ||
timeserial: response.timeserial, | ||
clientID: clientId, | ||
roomID: roomId, | ||
text: params.text, | ||
createdAt: Date(timeIntervalSince1970: createdAtInSeconds), | ||
metadata: params.metadata ?? [:], | ||
headers: params.headers ?? [:] | ||
) | ||
return message | ||
} | ||
|
||
internal func getOccupancy(roomId: String) async throws -> OccupancyEvent { | ||
let endpoint = "\(apiVersion)/rooms/\(roomId)/occupancy" | ||
return try await makeRequest(endpoint, method: "GET") | ||
} | ||
|
||
// TODO: Improve as part of occupancy/presence | ||
private func makeRequest<Response: Codable>(_ url: String, method: String, body: [String: Any]? = nil) async throws -> Response { | ||
try await withCheckedThrowingContinuation { continuation in | ||
do { | ||
try realtime.request(method, path: url, params: [:], body: body, headers: [:]) { paginatedResponse, error in | ||
if let error { | ||
// (CHA-M3e) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call. | ||
continuation.resume(throwing: ARTErrorInfo.create(from: error)) | ||
return | ||
} | ||
|
||
guard let firstItem = paginatedResponse?.items.first else { | ||
continuation.resume(throwing: ChatError.noItemInResponse) | ||
return | ||
} | ||
|
||
do { | ||
let decodedResponse = try DictionaryDecoder().decode(Response.self, from: firstItem) | ||
continuation.resume(returning: decodedResponse) | ||
} catch { | ||
continuation.resume(throwing: error) | ||
} | ||
} | ||
} catch { | ||
continuation.resume(throwing: error) | ||
} | ||
} | ||
} | ||
|
||
private func makePaginatedRequest<Response: Codable & Sendable & Equatable>( | ||
_ url: String, | ||
params: [String: String]? = nil, | ||
body: [String: Any]? = nil | ||
) async throws -> any PaginatedResult<Response> { | ||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<PaginatedResultWrapper<Response>, _>) in | ||
do { | ||
try realtime.request("GET", path: url, params: params, body: nil, headers: [:]) { paginatedResponse, error in | ||
ARTHTTPPaginatedCallbackWrapper<Response>(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) | ||
} | ||
} catch { | ||
continuation.resume(throwing: error) | ||
} | ||
} | ||
} | ||
|
||
internal enum ChatError: Error { | ||
case noItemInResponse | ||
} | ||
} | ||
|
||
internal struct DictionaryDecoder { | ||
private let decoder = JSONDecoder() | ||
|
||
// Function to decode from a dictionary | ||
internal func decode<T: Decodable>(_: T.Type, from dictionary: NSDictionary) throws -> T { | ||
let data = try JSONSerialization.data(withJSONObject: dictionary) | ||
return try decoder.decode(T.self, from: data) | ||
} | ||
|
||
// Function to decode from a dictionary array | ||
internal func decode<T: Decodable>(_: T.Type, from dictionary: [NSDictionary]) throws -> T { | ||
let data = try JSONSerialization.data(withJSONObject: dictionary) | ||
return try decoder.decode(T.self, from: data) | ||
} | ||
} |
Oops, something went wrong.