Skip to content

Commit

Permalink
Introduce group chat to xmtp-ios
Browse files Browse the repository at this point in the history
  • Loading branch information
nakajima committed Feb 1, 2024
1 parent c8e4027 commit 7cbc6d5
Show file tree
Hide file tree
Showing 23 changed files with 4,879 additions and 67 deletions.
5 changes: 2 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ let package = Package(
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),

.package(url: "https://github.com/GigaBitcoin/secp256k1.swift.git", exact: "0.10.0"),
.package(url: "https://github.com/argentlabs/web3.swift", from: "1.1.0"),
.package(url: "https://github.com/1024jp/GzipSwift", from: "5.2.0"),
.package(url: "https://github.com/bufbuild/connect-swift", exact: "0.3.0"),
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"),
.package(url: "https://github.com/xmtp/libxmtp-swift", revision: "92274fe"),
// .package(path: "../libxmtp-swift")
// .package(url: "https://github.com/xmtp/libxmtp-swift", revision: "e5d26a4"),
.package(path: "../libxmtp-swift")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
Expand Down
61 changes: 49 additions & 12 deletions Sources/XMTPiOS/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ public struct ClientOptions {
/// Specify which XMTP network to connect to. Defaults to ``.dev``
public var env: XMTPEnvironment = .dev

/// Optional: Specify self-reported version e.g. XMTPInbox/v1.0.0.
/// Specify whether the API client should use TLS security. In general this should only be false when using the `.local` environment.
public var isSecure: Bool = true

/// Specify whether the API client should use TLS security. In general this should only be false when using the `.local` environment.
/// /// Optional: Specify self-reported version e.g. XMTPInbox/v1.0.0.
public var appVersion: String?

public init(env: XMTPEnvironment = .dev, isSecure: Bool = true, appVersion: String? = nil) {
Expand Down Expand Up @@ -60,11 +60,12 @@ public struct ClientOptions {
/// 2. To sign a random salt used to encrypt the key bundle in storage. This happens every time the client is started, including the very first time).
///
/// > Important: The client connects to the XMTP `dev` environment by default. Use ``ClientOptions`` to change this and other parameters of the network connection.
public final class Client: Sendable {
public final class Client {
/// The wallet address of the ``SigningKey`` used to create this Client.
public let address: String
let privateKeyBundleV1: PrivateKeyBundleV1
let apiClient: ApiClient
let v3Client: LibXMTP.FfiXmtpClient

/// Access ``Conversations`` for this Client.
public lazy var conversations: Conversations = .init(client: self)
Expand Down Expand Up @@ -100,23 +101,40 @@ public final class Client: Sendable {
}

static func create(account: SigningKey, apiClient: ApiClient, options: ClientOptions? = nil) async throws -> Client {
let privateKeyBundleV1 = try await loadOrCreateKeys(for: account, apiClient: apiClient, options: options)
let (privateKeyBundleV1, source) = try await loadOrCreateKeys(for: account, apiClient: apiClient, options: options)

let dbURL = URL.documentsDirectory.appendingPathComponent("xmtp-\(account.address).db3")
let v3Client = try await LibXMTP.createClient(
logger: XMTPLogger(),
host: GRPCApiClient.envToUrl(env: apiClient.environment),
isSecure: apiClient.environment != .local,
db: dbURL.path,
encryptionKey: nil,
accountAddress: account.address,
legacyIdentitySource: source,
legacySignedPrivateKeyProto: nil
)

guard let textToSign = v3Client.textToSign() else {
throw ClientError.creationError("no text to sign")
}

let signature = try await account.sign(message: textToSign).rawData
try await v3Client.registerIdentity(recoverableWalletSignature: signature)

let client = try Client(address: account.address, privateKeyBundleV1: privateKeyBundleV1, apiClient: apiClient)
let client = try Client(address: account.address, privateKeyBundleV1: privateKeyBundleV1, apiClient: apiClient, v3Client: v3Client)
try await client.ensureUserContactPublished()

return client
}

static func loadOrCreateKeys(for account: SigningKey, apiClient: ApiClient, options: ClientOptions? = nil) async throws -> PrivateKeyBundleV1 {
// swiftlint:disable no_optional_try
static func loadOrCreateKeys(for account: SigningKey, apiClient: ApiClient, options: ClientOptions? = nil) async throws -> (PrivateKeyBundleV1, LegacyIdentitySource) {
if let keys = try await loadPrivateKeys(for: account, apiClient: apiClient, options: options) {
// swiftlint:enable no_optional_try
print("loading existing private keys.")
#if DEBUG
print("Loaded existing private keys.")
#endif
return keys
return (keys, .network)
} else {
#if DEBUG
print("No existing keys found, creating new bundle.")
Expand All @@ -133,7 +151,7 @@ public final class Client: Sendable {
Envelope(topic: .userPrivateStoreKeyBundle(account.address), timestamp: Date(), message: encryptedKeys.serializedData()),
])

return keys
return (keys, .keyGenerator)
}
}

Expand All @@ -155,6 +173,10 @@ public final class Client: Sendable {
return nil
}

public func canMessageV3(address: String) async throws -> Bool {
try await v3Client.canMessage(accountAddresses: [address]) == [true]
}

public static func from(bundle: PrivateKeyBundle, options: ClientOptions? = nil) async throws -> Client {
return try await from(v1Bundle: bundle.v1, options: options)
}
Expand All @@ -172,13 +194,28 @@ public final class Client: Sendable {
rustClient: client
)

return try Client(address: address, privateKeyBundleV1: v1Bundle, apiClient: apiClient)
let dbURL = URL.documentsDirectory.appendingPathComponent("xmtp-\(address).db3")
let v3Client = try await LibXMTP.createClient(
logger: XMTPLogger(),
host: GRPCApiClient.envToUrl(env: apiClient.environment),
isSecure: apiClient.environment != .local,
db: dbURL.path,
encryptionKey: nil,
accountAddress: address,
legacyIdentitySource: .static,
legacySignedPrivateKeyProto: try v1Bundle.identityKey.serializedData()
)

try await v3Client.registerIdentity(recoverableWalletSignature: nil)

return try Client(address: address, privateKeyBundleV1: v1Bundle, apiClient: apiClient, v3Client: v3Client)
}

init(address: String, privateKeyBundleV1: PrivateKeyBundleV1, apiClient: ApiClient) throws {
init(address: String, privateKeyBundleV1: PrivateKeyBundleV1, apiClient: ApiClient, v3Client: LibXMTP.FfiXmtpClient) throws {
self.address = address
self.privateKeyBundleV1 = privateKeyBundleV1
self.apiClient = apiClient
self.v3Client = v3Client
}

public var privateKeyBundle: PrivateKeyBundle {
Expand Down
5 changes: 4 additions & 1 deletion Sources/XMTPiOS/CodecRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
import Foundation

struct CodecRegistry {
var codecs: [String: any ContentCodec] = [TextCodec().id: TextCodec()]
var codecs: [String: any ContentCodec] = [
TextCodec().id: TextCodec(),
GroupMembershipChangedCodec().id: GroupMembershipChangedCodec()
]

mutating func register(codec: any ContentCodec) {
codecs[codec.id] = codec
Expand Down
40 changes: 40 additions & 0 deletions Sources/XMTPiOS/Codecs/GroupMembershipChanged.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// File.swift
//
//
// Created by Pat Nakajima on 2/1/24.
//

import Foundation
import LibXMTP

public typealias GroupMembershipChanges = Xmtp_Mls_MessageContents_GroupMembershipChanges

public let ContentTypeGroupMembershipChanged = ContentTypeID(authorityID: "xmtp.org", typeID: "group_membership_change", versionMajor: 1, versionMinor: 0)

public struct GroupMembershipChangedCodec: ContentCodec {

public typealias T = GroupMembershipChanges

public init() { }

public var contentType = ContentTypeGroupMembershipChanged

public func encode(content: GroupMembershipChanges, client _: Client) throws -> EncodedContent {
var encodedContent = EncodedContent()

encodedContent.type = ContentTypeGroupMembershipChanged
encodedContent.content = try content.serializedData()

return encodedContent
}

public func decode(content: EncodedContent, client _: Client) throws -> GroupMembershipChanges {
return try GroupMembershipChanges(serializedData: content.content)
}

public func fallback(content: GroupMembershipChanges) throws -> String? {
return nil
}
}

18 changes: 18 additions & 0 deletions Sources/XMTPiOS/Conversations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ public enum ConversationError: Error {
case recipientNotOnNetwork, recipientIsSender, v1NotSupported(String)
}

public enum GroupError: Error {
case emptyCreation
}

/// Handles listing and creating Conversations.
public actor Conversations {
var client: Client
Expand All @@ -13,6 +17,20 @@ public actor Conversations {
self.client = client
}

public func groups() async throws -> [Group] {
try await client.v3Client.conversations().sync()

return try await client.v3Client.conversations().list(opts: .init(createdAfterNs: nil, createdBeforeNs: nil, limit: nil)).map { $0.fromFFI(client: client) }
}

public func newGroup(with addresses: [String]) async throws -> Group {
if addresses.isEmpty {
throw GroupError.emptyCreation
}

return try await client.v3Client.conversations().createGroup(accountAddresses: addresses).fromFFI(client: client)
}

/// Import a previously seen conversation.
/// See Conversation.toTopicData()
public func importTopicData(data: Xmtp_KeystoreApi_V1_TopicMap.TopicData) -> Conversation {
Expand Down
14 changes: 14 additions & 0 deletions Sources/XMTPiOS/Extensions/Ffi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,17 @@ extension FfiV2SubscribeRequest {
}
}
}

// MARK: Group

extension FfiGroup {
func fromFFI(client: Client) -> Group {
Group(ffiGroup: self, client: client)
}
}

extension FfiGroupMember {
var fromFFI: Group.Member {
Group.Member(ffiGroupMember: self)
}
}
23 changes: 23 additions & 0 deletions Sources/XMTPiOS/Extensions/URL.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// File.swift
//
//
// Created by Pat Nakajima on 2/1/24.
//

import Foundation

extension URL {
static var documentsDirectory: URL {
guard let documentsDirectory = try? FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
) else {
fatalError("No documents directory")
}

return documentsDirectory
}
}
106 changes: 106 additions & 0 deletions Sources/XMTPiOS/Group.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//
// Group.swift
//
//
// Created by Pat Nakajima on 2/1/24.
//

import Foundation
import LibXMTP

public struct Group: Identifiable, Equatable, Hashable {
var ffiGroup: FfiGroup
var client: Client

public struct Member {
var ffiGroupMember: FfiGroupMember

public var accountAddress: String {
ffiGroupMember.accountAddress
}
}

public var id: Data {
ffiGroup.id()
}

public static func == (lhs: Group, rhs: Group) -> Bool {
lhs.id == rhs.id
}

public func hash(into hasher: inout Hasher) {
id.hash(into: &hasher)
}

public func members() async throws -> [Member] {
_ = try await ffiGroup.sync()
return try ffiGroup.listMembers().map(\.fromFFI)
}

public var cachedMembers: [Member] {
do {
return try ffiGroup.listMembers().map(\.fromFFI)
} catch {
return []
}
}

public func addMembers(addresses: [String]) async throws {
try await ffiGroup.addMembers(accountAddresses: addresses)
try await ffiGroup.sync()
}

public func removeMembers(addresses: [String]) async throws {
try await ffiGroup.removeMembers(accountAddresses: addresses)
try await ffiGroup.sync()
}

public func send<T>(content: T, options: SendOptions? = nil) async throws {
func encode<Codec: ContentCodec>(codec: Codec, content: Any) throws -> EncodedContent {
if let content = content as? Codec.T {
return try codec.encode(content: content, client: client)
} else {
throw CodecError.invalidContent
}
}

let codec = client.codecRegistry.find(for: options?.contentType)
var encoded = try encode(codec: codec, content: content)

func fallback<Codec: ContentCodec>(codec: Codec, content: Any) throws -> String? {
if let content = content as? Codec.T {
return try codec.fallback(content: content)
} else {
throw CodecError.invalidContent
}
}

if let fallback = try fallback(codec: codec, content: content) {
encoded.fallback = fallback
}

if let compression = options?.compression {
encoded = try encoded.compress(compression)
}

try await ffiGroup.send(contentBytes: encoded.serializedData())
}

public func messages() async throws -> [DecodedMessage] {
// TODO: paginate
try await ffiGroup.sync()
let messages = try ffiGroup.findMessages(opts: .init(sentBeforeNs: nil, sentAfterNs: nil, limit: nil))

return try messages.map { ffiMessage in
let encodedContent = try EncodedContent(serializedData: ffiMessage.content)

return DecodedMessage(
client: client,
topic: "",
encodedContent: encodedContent,
senderAddress: ffiMessage.addrFrom,
sent: Date(timeIntervalSince1970: TimeInterval(ffiMessage.sentAtNs / 1_000_000_000))
)
}
}
}
Loading

0 comments on commit 7cbc6d5

Please sign in to comment.