Skip to content

Commit

Permalink
Spec complete for sending and receiving messages
Browse files Browse the repository at this point in the history
  • Loading branch information
lawrence-forooghian authored and umair-ably committed Oct 14, 2024
2 parents 96cdfa8 + 05836a6 commit 2d57266
Show file tree
Hide file tree
Showing 33 changed files with 2,164 additions and 111 deletions.
148 changes: 148 additions & 0 deletions Example/AblyChatExample/MessageDemoView.swift
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()
}
4 changes: 4 additions & 0 deletions Example/AblyChatExample/Mocks/Misc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import Ably
import AblyChat

final class MockMessagesPaginatedResult: PaginatedResult {
static func == (_: MockMessagesPaginatedResult, _: MockMessagesPaginatedResult) -> Bool {
fatalError("dsgdsg")
}

typealias T = Message

let clientID: String
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 @@ -14,6 +14,10 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
let channels = Channels()

final class Channels: RealtimeChannelsProtocol {
func get(_: String, options _: ARTRealtimeChannelOptions) -> MockRealtime.Channel {
fatalError("Not implemented")
}

func get(_: String) -> Channel {
fatalError("Not implemented")
}
Expand Down
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ let package = Package(
name: "Ably",
package: "ably-cocoa"
),
.product(
name: "AsyncAlgorithms",
package: "swift-async-algorithms"
),
]
),
.testTarget(
Expand Down
68 changes: 68 additions & 0 deletions [email protected]
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"
),
]
),
]
)
143 changes: 143 additions & 0 deletions Sources/AblyChat/ChatAPI.swift
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)
}
}
Loading

0 comments on commit 2d57266

Please sign in to comment.