Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/xmtp/xmtp-ios into wallet-c…
Browse files Browse the repository at this point in the history
…onnect-v2
  • Loading branch information
nplasterer committed Dec 5, 2023
2 parents f1d344b + bf38bb9 commit 360654b
Show file tree
Hide file tree
Showing 34 changed files with 2,180 additions and 491 deletions.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2022 XMTP
Copyright (c) 2023 XMTP (xmtp.org)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/xmtp/xmtp-rust-swift",
"state" : {
"revision" : "4a76e5401fa780c40610e2f0d248f695261d08dd",
"version" : "0.3.1-beta0"
"branch" : "main",
"revision" : "eb931c2f467c2a71a621f54d7ae22887b234c13a"
}
}
],
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ let package = Package(
.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", from: "0.3.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/xmtp-rust-swift", from: "0.3.1-beta0"),
.package(url: "https://github.com/xmtp/xmtp-rust-swift", branch: "main"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,18 @@ let decodedConversation = containerAgain.decode(with: client)
try await decodedConversation.send(text: "hi")
```

## Request and respect user consent

![Feature status](https://img.shields.io/badge/Feature_status-Alpha-orange)

The user consent feature enables your app to request and respect user consent preferences. With this feature, another blockchain account address registered on the XMTP network can have one of three consent preference values:

- Unknown
- Allowed
- Denied

To learn more, see [Request and respect user consent](https://xmtp.org/docs/build/user-consent).

## Handle different content types

All of the send functions support `SendOptions` as an optional parameter. The `contentType` option allows specifying different types of content other than the default simple string standard content type, which is identified with content type identifier `ContentTypeText`.
Expand Down
12 changes: 12 additions & 0 deletions Sources/XMTP/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ public final class Client: Sendable {
public func canMessage(_ peerAddress: String) async throws -> Bool {
return try await query(topic: .contact(peerAddress)).envelopes.count > 0
}

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

let client = try await XMTPRust.create_client(GRPCApiClient.envToUrl(env: options.api.env), options.api.env != .local)
let apiClient = try GRPCApiClient(
environment: options.api.env,
secure: options.api.isSecure,
rustClient: client
)
return try await apiClient.query(topic: .contact(peerAddress)).envelopes.count > 0
}

public func importConversation(from conversationData: Data) throws -> Conversation? {
let jsonDecoder = JSONDecoder()
Expand Down
4 changes: 2 additions & 2 deletions Sources/XMTP/Codecs/RemoteAttachmentCodec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public struct RemoteAttachment {

func ensureSchemeMatches() throws {
if !url.hasPrefix(scheme.rawValue) {
throw RemoteAttachmentError.invalidScheme("scheme must be https://")
throw RemoteAttachmentError.invalidScheme("scheme must be https")
}
}

Expand Down Expand Up @@ -174,7 +174,7 @@ public struct RemoteAttachmentCodec: ContentCodec {
throw RemoteAttachmentError.invalidScheme("no scheme parameter")
}

if (!schemeString.starts(with: "https")) {
if (!schemeString.hasPrefix(RemoteAttachment.Scheme.https.rawValue)) {
throw RemoteAttachmentError.invalidScheme("invalid scheme value. must start with https")
}

Expand Down
160 changes: 159 additions & 1 deletion Sources/XMTP/Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,137 @@
//

import Foundation
import XMTPRust


public typealias PrivatePreferencesAction = Xmtp_MessageContents_PrivatePreferencesAction

public enum ConsentState: String, Codable {
case allowed, denied, unknown
}

struct ConsentListEntry: Codable, Hashable {
enum EntryType: String, Codable {
case address
}

static func address(_ address: String, type: ConsentState = .unknown) -> ConsentListEntry {
ConsentListEntry(value: address, entryType: .address, consentType: type)
}

var value: String
var entryType: EntryType
var consentType: ConsentState

var key: String {
"\(entryType)-\(value)"
}
}

public enum ContactError: Error {
case invalidIdentifier
}

class ConsentList {
var entries: [String: ConsentState] = [:]
var publicKey: Data
var privateKey: Data
var identifier: String?

var client: Client

init(client: Client) {
self.client = client
self.privateKey = client.privateKeyBundleV1.identityKey.secp256K1.bytes
self.publicKey = client.privateKeyBundleV1.identityKey.publicKey.secp256K1Uncompressed.bytes
// swiftlint:disable no_optional_try
self.identifier = try? XMTPRust.generate_private_preferences_topic_identifier(RustVec(privateKey)).toString()
// swiftlint:enable no_optional_try
}

func load() async throws -> ConsentList {
guard let identifier = identifier else {
throw ContactError.invalidIdentifier
}

let envelopes = try await client.query(topic: .preferenceList(identifier), pagination: Pagination(direction: .ascending))

let consentList = ConsentList(client: client)

var preferences: [PrivatePreferencesAction] = []

for envelope in envelopes.envelopes {


let payload = try XMTPRust.ecies_decrypt_k256_sha3_256(
RustVec(publicKey),
RustVec(privateKey),
RustVec(envelope.message)
)

preferences.append(try PrivatePreferencesAction(serializedData: Data(payload)))
}

preferences.forEach { preference in
preference.allow.walletAddresses.forEach { address in
consentList.allow(address: address)
}
preference.block.walletAddresses.forEach { address in
consentList.deny(address: address)
}
}

return consentList
}

func publish(entry: ConsentListEntry) async throws {
guard let identifier = identifier else {
throw ContactError.invalidIdentifier
}

var payload = PrivatePreferencesAction()
switch entry.consentType {
case .allowed:
payload.allow.walletAddresses = [entry.value]
case .denied:
payload.block.walletAddresses = [entry.value]
case .unknown:
payload.messageType = nil
}

let message = try XMTPRust.ecies_encrypt_k256_sha3_256(
RustVec(publicKey),
RustVec(privateKey),
RustVec(payload.serializedData())
)

let envelope = Envelope(
topic: Topic.preferenceList(identifier),
timestamp: Date(),
message: Data(message)
)

try await client.publish(envelopes: [envelope])
}

func allow(address: String) -> ConsentListEntry {
entries[ConsentListEntry.address(address).key] = .allowed

return .address(address, type: .allowed)
}

func deny(address: String) -> ConsentListEntry {
entries[ConsentListEntry.address(address).key] = .denied

return .address(address, type: .denied)
}

func state(address: String) -> ConsentState {
let state = entries[ConsentListEntry.address(address).key]

return state ?? .unknown
}
}

/// Provides access to contact bundles.
public actor Contacts {
Expand All @@ -17,8 +148,35 @@ public actor Contacts {
// Whether or not we have sent invite/intro to this contact
var hasIntroduced: [String: Bool] = [:]

init(client: Client) {
var consentList: ConsentList

init(client: Client) {
self.client = client
self.consentList = ConsentList(client: client)
}

public func refreshConsentList() async throws {
self.consentList = try await ConsentList(client: client).load()
}

public func isAllowed(_ address: String) -> Bool {
return consentList.state(address: address) == .allowed
}

public func isDenied(_ address: String) -> Bool {
return consentList.state(address: address) == .denied
}

public func allow(addresses: [String]) async throws {
for address in addresses {
try await ConsentList(client: client).publish(entry: consentList.allow(address: address))
}
}

public func deny(addresses: [String]) async throws {
for address in addresses {
try await ConsentList(client: client).publish(entry: consentList.deny(address: address))
}
}

func markIntroduced(_ peerAddress: String, _ isIntroduced: Bool) {
Expand Down
66 changes: 53 additions & 13 deletions Sources/XMTP/Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ public enum Conversation: Sendable {
case v1, v2
}

public func consentState() async -> ConsentState {
let client: Client

switch self {
case .v1(let conversationV1):
client = conversationV1.client
case .v2(let conversationV2):
client = conversationV2.client
}

return await client.contacts.consentList.state(address: peerAddress)
}

public var version: Version {
switch self {
case .v1:
Expand Down Expand Up @@ -82,7 +95,7 @@ public enum Conversation: Sendable {
/// See Conversations.importTopicData()
public func toTopicData() -> Xmtp_KeystoreApi_V1_TopicMap.TopicData {
Xmtp_KeystoreApi_V1_TopicMap.TopicData.with {
$0.createdNs = UInt64(createdAt.timeIntervalSince1970 * 1_000) * 1_000_000
$0.createdNs = UInt64(createdAt.timeIntervalSince1970 * 1000) * 1_000_000
$0.peerAddress = peerAddress
if case let .v2(cv2) = self {
$0.invitation = Xmtp_MessageContents_InvitationV1.with {
Expand All @@ -105,6 +118,15 @@ public enum Conversation: Sendable {
}
}

public func decrypt(_ envelope: Envelope) throws -> DecryptedMessage {
switch self {
case let .v1(conversationV1):
return try conversationV1.decrypt(envelope: envelope)
case let .v2(conversationV2):
return try conversationV2.decrypt(envelope: envelope)
}
}

public func encode<Codec: ContentCodec, T>(codec: Codec, content: T) async throws -> Data where Codec.T == T {
switch self {
case let .v1:
Expand All @@ -123,16 +145,16 @@ public enum Conversation: Sendable {
}
}

// This is a convenience for invoking the underlying `client.publish(prepared.envelopes)`
// If a caller has a `Client` handy, they may opt to do that directly instead.
@discardableResult public func send(prepared: PreparedMessage) async throws -> String {
switch self {
case let .v1(conversationV1):
return try await conversationV1.send(prepared: prepared)
case let .v2(conversationV2):
return try await conversationV2.send(prepared: prepared)
}
}
// This is a convenience for invoking the underlying `client.publish(prepared.envelopes)`
// If a caller has a `Client` handy, they may opt to do that directly instead.
@discardableResult public func send(prepared: PreparedMessage) async throws -> String {
switch self {
case let .v1(conversationV1):
return try await conversationV1.send(prepared: prepared)
case let .v2(conversationV2):
return try await conversationV2.send(prepared: prepared)
}
}

@discardableResult public func send<T>(content: T, options: SendOptions? = nil, fallback _: String? = nil) async throws -> String {
switch self {
Expand Down Expand Up @@ -198,16 +220,34 @@ public enum Conversation: Sendable {
}
}

public func streamDecryptedMessages() -> AsyncThrowingStream<DecryptedMessage, Error> {
switch self {
case let .v1(conversation):
return conversation.streamDecryptedMessages()
case let .v2(conversation):
return conversation.streamDecryptedMessages()
}
}

/// List messages in the conversation
public func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] {
public func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] {
switch self {
case let .v1(conversationV1):
return try await conversationV1.messages(limit: limit, before: before, after: after, direction: direction)
return try await conversationV1.messages(limit: limit, before: before, after: after, direction: direction)
case let .v2(conversationV2):
return try await conversationV2.messages(limit: limit, before: before, after: after, direction: direction)
}
}

public func decryptedMessages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecryptedMessage] {
switch self {
case let .v1(conversationV1):
return try await conversationV1.decryptedMessages(limit: limit, before: before, after: after, direction: direction)
case let .v2(conversationV2):
return try await conversationV2.decryptedMessages(limit: limit, before: before, after: after, direction: direction)
}
}

var client: Client {
switch self {
case let .v1(conversationV1):
Expand Down
Loading

0 comments on commit 360654b

Please sign in to comment.