Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce group chat to xmtp-ios #229

Merged
merged 43 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
7cbc6d5
Introduce group chat to xmtp-ios
nakajima Feb 1, 2024
ae3e918
fix lint, add address to example app for groups
nakajima Feb 1, 2024
2dcd87e
paginate
nakajima Feb 1, 2024
58ed562
Handle membership changes in example app
nakajima Feb 1, 2024
d9fc466
Make syncing explicit
nakajima Feb 1, 2024
039687b
Pass legacySignedPrivateKeyProto
nakajima Feb 2, 2024
7734588
add more validations
nakajima Feb 2, 2024
04a95aa
Add ClientOptions.enableAlphaMLS
nakajima Feb 2, 2024
624e449
Make group changes codec opt-in
nakajima Feb 2, 2024
13b7919
Point to libxmtp-swift package, not local filesystem
nakajima Feb 2, 2024
aa1b3cf
Update Package.swift
nakajima Feb 2, 2024
ed53157
bump podspec
nakajima Feb 2, 2024
d1dbdef
include env in db url
nakajima Feb 2, 2024
e29fde8
extract method
nakajima Feb 2, 2024
2c3d5e3
return members as strings not objects
nakajima Feb 5, 2024
68640ed
Error when trying to enable alpha MLS with no signer and no keys
nakajima Feb 5, 2024
42bc273
pass encryption key
nakajima Feb 5, 2024
f8b74cc
Move client test to clientests
nakajima Feb 5, 2024
70a61ee
Pull v3 client into its own PR
nakajima Feb 5, 2024
1900133
Merge branch 'v3-client' into v3-groups
nakajima Feb 5, 2024
45da043
Update GroupMembershipChanged.swift
nakajima Feb 5, 2024
327b000
Add tests
nakajima Feb 5, 2024
b74d035
rename mls alpha
nakajima Feb 5, 2024
646cedc
Merge branch 'v3-groups' of https://github.com/xmtp/xmtp-ios into v3-…
nakajima Feb 5, 2024
810053e
Merge branch 'v3-client' of https://github.com/xmtp/xmtp-ios into v3-…
nakajima Feb 5, 2024
6cd7036
fix codec
nakajima Feb 5, 2024
f71a3d6
cleanup
nakajima Feb 5, 2024
534a67f
Merge branch 'v3-client' of https://github.com/xmtp/xmtp-ios into v3-…
nakajima Feb 5, 2024
8086b78
Fix example app (needed to add manual syncs)
nakajima Feb 5, 2024
d768ba9
Merge branch 'main' into v3-groups
nakajima Feb 5, 2024
9c4a8bc
Update GroupMembershipChanged.swift
nakajima Feb 5, 2024
809c6e0
fix nav
nakajima Feb 6, 2024
7d4316a
cleanup
nakajima Feb 6, 2024
e5118ca
add group settings view
nakajima Feb 6, 2024
05be35a
rename members to member addresses
nakajima Feb 7, 2024
71bf8aa
Merge branch 'main' of https://github.com/xmtp/xmtp-ios into v3-groups
nakajima Feb 7, 2024
a5eacd3
uncomment local node skip
nakajima Feb 7, 2024
7ef5510
improve group error
nakajima Feb 7, 2024
33c20c0
use group id for topic
nakajima Feb 7, 2024
7f09636
fix lint
nakajima Feb 7, 2024
15d34aa
allow mls on dev, other cleanup
nakajima Feb 7, 2024
8b5706c
V3 group streaming (#239)
nakajima Feb 8, 2024
c086f9f
bump podspec
nakajima Feb 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,12 @@ 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: "1d7068f181a402b2cd8b9520ccb768e65a55ec32"),
nakajima marked this conversation as resolved.
Show resolved Hide resolved
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
Expand All @@ -39,7 +37,7 @@ let package = Package(
"web3.swift",
.product(name: "Gzip", package: "GzipSwift"),
.product(name: "Connect", package: "connect-swift"),
.product(name: "LibXMTP", package: "libxmtp-swift"),
.product(name: "LibXMTP", package: "libxmtp-swift")
]
),
.target(
Expand Down
96 changes: 83 additions & 13 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 All @@ -44,11 +44,20 @@ public struct ClientOptions {
/// `preCreateIdentityCallback` will be called immediately before a Create Identity wallet signature is requested from the user.
public var preCreateIdentityCallback: PreEventCallback?

public init(api: Api = Api(), codecs: [any ContentCodec] = [], preEnableIdentityCallback: PreEventCallback? = nil, preCreateIdentityCallback: PreEventCallback? = nil) {
public var enableAlphaMLS: Bool = false

public init(
api: Api = Api(),
codecs: [any ContentCodec] = [],
preEnableIdentityCallback: PreEventCallback? = nil,
preCreateIdentityCallback: PreEventCallback? = nil,
enableAlphaMLS: Bool = false
) {
self.api = api
self.codecs = codecs
self.preEnableIdentityCallback = preEnableIdentityCallback
self.preCreateIdentityCallback = preCreateIdentityCallback
self.enableAlphaMLS = enableAlphaMLS
}
}

Expand All @@ -60,11 +69,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 +110,48 @@ 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 v3Client: FfiXmtpClient?

if options?.enableAlphaMLS == true && options?.api.env == .local {
let dbURL = URL.documentsDirectory.appendingPathComponent("xmtp-\(account.address).db3")
nakajima marked this conversation as resolved.
Show resolved Hide resolved
v3Client = try await LibXMTP.createClient(
logger: XMTPLogger(),
host: GRPCApiClient.envToUrl(env: apiClient.environment),
isSecure: apiClient.environment != .local,
db: dbURL.path,
encryptionKey: nil,
nakajima marked this conversation as resolved.
Show resolved Hide resolved
accountAddress: account.address,
legacyIdentitySource: source,
legacySignedPrivateKeyProto: try privateKeyBundleV1.toV2().identityKey.serializedData()
)

let client = try Client(address: account.address, privateKeyBundleV1: privateKeyBundleV1, apiClient: apiClient)
if let textToSign = v3Client?.textToSign() {
let signature = try await account.sign(message: textToSign).rawData
try await v3Client?.registerIdentity(recoverableWalletSignature: signature)
}
} else {
v3Client = nil
}

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

for codec in (options?.codecs ?? []) {
client.register(codec: codec)
}

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 +168,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 +190,14 @@ public final class Client: Sendable {
return nil
}

public func canMessageV3(address: String) async throws -> Bool {
guard let v3Client else {
return false
}

return try await v3Client.canMessage(accountAddresses: [address]) == [true]
}

public static func from(bundle: PrivateKeyBundle, options: ClientOptions? = nil) async throws -> Client {
nakajima marked this conversation as resolved.
Show resolved Hide resolved
return try await from(v1Bundle: bundle.v1, options: options)
}
Expand All @@ -172,13 +215,40 @@ public final class Client: Sendable {
rustClient: client
)

return try Client(address: address, privateKeyBundleV1: v1Bundle, apiClient: apiClient)
let v3Client: FfiXmtpClient?

if options.enableAlphaMLS == true && options.api.env == .local {
let dbURL = URL.documentsDirectory.appendingPathComponent("xmtp-\(address).db3")
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.toV2().identityKey.serializedData()
)

try await v3Client?.registerIdentity(recoverableWalletSignature: nil)
nakajima marked this conversation as resolved.
Show resolved Hide resolved
} else {
v3Client = nil
}

nakajima marked this conversation as resolved.
Show resolved Hide resolved
let result = try Client(address: address, privateKeyBundleV1: v1Bundle, apiClient: apiClient, v3Client: v3Client)

for codec in options.codecs {
result.register(codec: codec)
}

return result
}

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
4 changes: 3 additions & 1 deletion Sources/XMTPiOS/CodecRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
import Foundation

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

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

76 changes: 76 additions & 0 deletions Sources/XMTPiOS/Conversations.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import Foundation
import LibXMTP

public enum ConversationError: Error {
case recipientNotOnNetwork, recipientIsSender, v1NotSupported(String)
}

public enum GroupError: Error {
case alphaMLSNotEnabled, emptyCreation, memberCannotBeSelf, memberNotRegistered([String])
nakajima marked this conversation as resolved.
Show resolved Hide resolved
}

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

public func sync() async throws {
guard let v3Client = client.v3Client else {
return
}

try await v3Client.conversations().sync()
}

public func groups(createdAfter: Date? = nil, createdBefore: Date? = nil, limit: Int? = nil) async throws -> [Group] {
guard let v3Client = client.v3Client else {
return []
}

var options = FfiListConversationsOptions(createdAfterNs: nil, createdBeforeNs: nil, limit: nil)

if let createdAfter {
options.createdAfterNs = Int64(createdAfter.millisecondsSinceEpoch)
}

if let createdBefore {
options.createdBeforeNs = Int64(createdBefore.millisecondsSinceEpoch)
}

if let limit {
options.limit = Int64(limit)
}

return try await v3Client.conversations().list(opts: options).map { $0.fromFFI(client: client) }
}

public func newGroup(with addresses: [String]) async throws -> Group {
guard let v3Client = client.v3Client else {
throw GroupError.alphaMLSNotEnabled
}

if addresses.isEmpty {
throw GroupError.emptyCreation
}

nakajima marked this conversation as resolved.
Show resolved Hide resolved
if addresses.first(where: { $0.lowercased() == client.address.lowercased() }) != nil {
throw GroupError.memberCannotBeSelf
}

let erroredAddresses = try await withThrowingTaskGroup(of: (String?).self) { group in
for address in addresses {
group.addTask {
if try await self.client.canMessageV3(address: address) {
return nil
} else {
return address
}
}
}
Comment on lines +115 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like how you handled this. I can improve this on android for sure.


var results: [String] = []
for try await result in group {
if let result {
results.append(result)
}
}

return results
}

if !erroredAddresses.isEmpty {
throw GroupError.memberNotRegistered(erroredAddresses)
}

return try await 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)
}
}
24 changes: 24 additions & 0 deletions Sources/XMTPiOS/Extensions/URL.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// File.swift
//
//
// Created by Pat Nakajima on 2/1/24.
//

import Foundation

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

return documentsDirectory
}
}
Loading
Loading