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 all 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
4 changes: 1 addition & 3 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: "503086d"),
// .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 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
12 changes: 5 additions & 7 deletions Sources/XMTPiOS/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public final class Client {
public static func create(account: SigningKey, options: ClientOptions? = nil) async throws -> Client {
let options = options ?? ClientOptions()
do {
let client = try await LibXMTP.createV2Client(host: options.api.env.url, isSecure: options.api.env != .local)
let client = try await LibXMTP.createV2Client(host: options.api.env.url, isSecure: options.api.env.isSecure)
let apiClient = try GRPCApiClient(
environment: options.api.env,
secure: options.api.isSecure,
Expand All @@ -126,12 +126,12 @@ public final class Client {
privateKeyBundleV1: PrivateKeyBundleV1,
signingKey: SigningKey?
) async throws -> FfiXmtpClient? {
if options?.mlsAlpha == true, options?.api.env == .local {
if options?.mlsAlpha == true, options?.api.env.supportsMLS == true {
let dbURL = URL.documentsDirectory.appendingPathComponent("xmtp-\(options?.api.env.rawValue ?? "")-\(address).db3")
let v3Client = try await LibXMTP.createClient(
logger: XMTPLogger(),
host: (options?.api.env ?? .local).url,
isSecure: (options?.api.env ?? .local) != .local,
isSecure: options?.api.env.isSecure == true,
db: dbURL.path,
encryptionKey: options?.mlsEncryptionKey,
accountAddress: address,
Expand Down Expand Up @@ -248,8 +248,7 @@ public final class Client {
let address = try v1Bundle.identityKey.publicKey.recoverWalletSignerPublicKey().walletAddress

let options = options ?? ClientOptions()

let client = try await LibXMTP.createV2Client(host: options.api.env.url, isSecure: options.api.env != .local)
let client = try await LibXMTP.createV2Client(host: options.api.env.url, isSecure: options.api.env.isSecure)
let apiClient = try GRPCApiClient(
environment: options.api.env,
secure: options.api.isSecure,
Expand Down Expand Up @@ -302,8 +301,7 @@ public final class Client {

public static func canMessage(_ peerAddress: String, options: ClientOptions? = nil) async throws -> Bool {
let options = options ?? ClientOptions()

let client = try await LibXMTP.createV2Client(host: options.api.env.url, isSecure: options.api.env != .local)
let client = try await LibXMTP.createV2Client(host: options.api.env.url, isSecure: options.api.env.isSecure)
let apiClient = try GRPCApiClient(
environment: options.api.env,
secure: options.api.isSecure,
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
43 changes: 43 additions & 0 deletions Sources/XMTPiOS/Codecs/GroupMembershipChanged.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// GroupMembershipChanged.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
}

public func shouldPush(content: GroupMembershipChanges) throws -> Bool {
false
}
}

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

public enum ConversationError: Error, CustomStringConvertible {
case recipientNotOnNetwork, recipientIsSender, v1NotSupported(String)
Expand All @@ -15,15 +16,130 @@ public enum ConversationError: Error, CustomStringConvertible {
}
}

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

public var description: String {
switch self {
case .alphaMLSNotEnabled:
return "GroupError.alphaMLSNotEnabled"
case .emptyCreation:
return "GroupError.emptyCreation you cannot create an empty group"
case .memberCannotBeSelf:
return "GroupError.memberCannotBeSelf you cannot add yourself to a group"
case .memberNotRegistered(let array):
return "GroupError.memberNotRegistered members not registered: \(array.joined(separator: ", "))"
}
}
}

final class GroupStreamCallback: FfiConversationCallback {
let client: Client
let callback: (Group) -> Void

init(client: Client, callback: @escaping (Group) -> Void) {
self.client = client
self.callback = callback
}

func onConversation(conversation: FfiGroup) {
self.callback(conversation.fromFFI(client: client))
}
}

/// Handles listing and creating Conversations.
public actor Conversations {
var client: Client
var conversationsByTopic: [String: Conversation] = [:]
let streamHolder = StreamHolder()

init(client: Client) {
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 streamGroups() async throws -> AsyncThrowingStream<Group, Error> {
AsyncThrowingStream { continuation in
Task {
self.streamHolder.stream = try await self.client.v3Client?.conversations().stream(
callback: GroupStreamCallback(client: self.client) { group in
continuation.yield(group)
}
)
}
}
}

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
4 changes: 4 additions & 0 deletions Sources/XMTPiOS/Extensions/Date.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ extension Date {
var millisecondsSinceEpoch: Double {
timeIntervalSince1970 * 1000
}

init(millisecondsSinceEpoch: Int64) {
self.init(timeIntervalSince1970: TimeInterval(millisecondsSinceEpoch / 1_000_000_000))
}
}
30 changes: 30 additions & 0 deletions Sources/XMTPiOS/Extensions/Ffi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,33 @@ extension FfiV2SubscribeRequest {
}
}
}

// MARK: Messages

extension FfiMessage {
func fromFFI(client: Client) throws -> DecodedMessage {
let encodedContent = try EncodedContent(serializedData: content)

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

// MARK: Group

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

extension FfiGroupMember {
var fromFFI: Group.Member {
Group.Member(ffiGroupMember: self)
}
}
Loading
Loading