From 91e5e7b47444a411f8dcadf6dc90201b321f8811 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:36:11 -0700 Subject: [PATCH 01/16] feat: add strong typing to client --- src/Client.ts | 85 +++++++++++++++++++----- src/Message.ts | 68 ++++++------------- src/conversations/Conversation.ts | 87 +++++++++++-------------- src/conversations/Conversations.ts | 59 +++++++++-------- src/index.ts | 8 +-- test/Client.test.ts | 2 +- test/Message.test.ts | 3 + test/conversations/Conversation.test.ts | 18 ++--- 8 files changed, 174 insertions(+), 156 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 7e4e1912..37b77737 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -11,8 +11,8 @@ import { utils } from 'ethers' import { Signer } from './types/Signer' import { Conversations } from './conversations' import { ContentTypeText, TextCodec } from './codecs/Text' -import { ContentTypeId, ContentCodec } from './MessageContent' -import { compress } from './Compression' +import { ContentTypeId, ContentCodec, EncodedContent } from './MessageContent' +import { compress, decompress } from './Compression' import { content as proto, messageApi } from '@xmtp/proto' import { decodeContactBundle, encodeContactBundle } from './ContactBundle' import HttpApiClient, { @@ -41,6 +41,7 @@ import { } from './keystore/persistence' import { hasMetamaskWithSnaps } from './keystore/snapHelpers' import { version as snapVersion, package as snapPackage } from './snapInfo.json' +import { CompositeCodec } from './codecs/Composite' const { Compression } = proto // eslint-disable @typescript-eslint/explicit-module-boundary-types @@ -203,6 +204,8 @@ export type ClientOptions = Flatten< PreEventCallbackOptions > +export type ExtractDecodedType = C extends ContentCodec ? T : never + /** * Provide a default client configuration. These settings can be used on their own, or as a starting point for custom configurations * @@ -244,7 +247,7 @@ export function defaultOptions(opts?: Partial): ClientOptions { * Client class initiates connection to the XMTP network. * Should be created with `await Client.create(options)` */ -export default class Client { +export default class Client { address: string keystore: Keystore apiClient: ApiClient @@ -256,7 +259,7 @@ export default class Client { > // addresses and key bundles that we have witnessed private _backupClient: BackupClient - private readonly _conversations: Conversations + private readonly _conversations: Conversations // eslint-disable-next-line @typescript-eslint/no-explicit-any private _codecs: Map> private _maxContentSize: number @@ -286,7 +289,7 @@ export default class Client { /** * @type {Conversations} */ - get conversations(): Conversations { + get conversations(): Conversations { return this._conversations } @@ -304,10 +307,10 @@ export default class Client { * @param wallet the wallet as a Signer instance * @param opts specify how to to connect to the network */ - static async create( + static async create[]>( wallet: Signer | null, - opts?: Partial - ): Promise { + opts?: Partial & { codecs?: U } + ): Promise>> { const options = defaultOptions(opts) const apiClient = options.apiClientFactory(options) const keystore = await bootstrapKeystore(options, apiClient, wallet) @@ -317,7 +320,7 @@ export default class Client { const address = publicKeyBundle.walletSignatureAddress() apiClient.setAuthenticator(new KeystoreAuthenticator(keystore)) const backupClient = await Client.setupBackupClient(address, options.env) - const client = new Client( + const client = new Client>( publicKeyBundle, apiClient, backupClient, @@ -337,9 +340,9 @@ export default class Client { * impersonate a user on the XMTP network and read the user's * messages. */ - static async getKeys( + static async getKeys( wallet: Signer | null, - opts?: Partial + opts?: Partial & { codecs?: U } ): Promise { const client = await Client.create(wallet, opts) const keys = await client.keystore.getPrivateKeyBundle() @@ -623,11 +626,7 @@ export default class Client { * Convert arbitrary content into a serialized `EncodedContent` instance * with the given options */ - async encodeContent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - content: any, - options?: SendOptions - ): Promise { + async encodeContent(content: T, options?: SendOptions): Promise { const contentType = options?.contentType || ContentTypeText const codec = this.codecFor(contentType) if (!codec) { @@ -646,6 +645,39 @@ export default class Client { return proto.EncodedContent.encode(encoded).finish() } + async decodeContent(contentBytes: Uint8Array): Promise<{ + content: T + contentType: ContentTypeId + error?: Error + contentFallback?: string + }> { + const encodedContent = proto.EncodedContent.decode(contentBytes) + + if (!encodedContent.type) { + throw new Error('missing content type') + } + + let content: any // eslint-disable-line @typescript-eslint/no-explicit-any + const contentType = new ContentTypeId(encodedContent.type) + let error: Error | undefined + + await decompress(encodedContent, 1000) + + const codec = this.codecFor(contentType) + if (codec) { + content = codec.decode(encodedContent as EncodedContent, this) + } else { + error = new Error('unknown content type ' + contentType) + } + + return { + content, + contentType, + error, + contentFallback: encodedContent.fallback, + } + } + listInvitations(opts?: ListMessagesOptions): Promise { return this.listEnvelopes( buildUserInviteTopic(this.address), @@ -714,6 +746,8 @@ export default class Client { } } +export type AnyClient = Client[]> + function createHttpApiClientFromOptions(options: NetworkOptions): ApiClient { const apiUrl = options.apiUrl || ApiUrls[options.env] return new HttpApiClient(apiUrl, { appVersion: options.appVersion }) @@ -829,3 +863,22 @@ async function bootstrapKeystore( } throw new Error('No keystore providers available') } + +export type ClientReturnType = C extends Client ? T : never + +// async function stronglyTypedClient() { +// const c = await Client.create(null, { +// codecs: [new CompositeCodec(), new TextCodec()], +// }) + +// const convos = await c.conversations.list() +// convos[0].send('hello') +// convos[0].send(123) + +// for await (const msg of await convos[0].streamMessages()) { +// // Let's see what content type this is? +// // +// // +// console.log(msg.content) +// } +// } diff --git a/src/Message.ts b/src/Message.ts index 552f6dcd..f149df52 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -15,6 +15,7 @@ import { PublicKeyBundle, PublicKey } from './crypto' import { bytesToHex } from './crypto/utils' import { sha256 } from './crypto/encryption' import { + ContentCodec, ContentTypeFallback, ContentTypeId, EncodedContent, @@ -23,6 +24,7 @@ import { dateToNs, nsToDate } from './utils' import { decompress } from './Compression' import { Keystore } from './keystore' import { buildDecryptV1Request, getResultOrThrow } from './utils/keystore' +import { AnyClient, ClientReturnType } from './Client' const headerBytesAndCiphertext = ( msg: proto.Message @@ -228,16 +230,16 @@ export class MessageV2 extends MessageBase implements proto.MessageV2 { export type Message = MessageV1 | MessageV2 -export class DecodedMessage { +export class DecodedMessage { id: string messageVersion: 'v1' | 'v2' senderAddress: string recipientAddress?: string sent: Date contentTopic: string - conversation: Conversation + conversation: Conversation contentType: ContentTypeId - content: any // eslint-disable-line @typescript-eslint/no-explicit-any + content: T // eslint-disable-line @typescript-eslint/no-explicit-any error?: Error contentBytes: Uint8Array contentFallback?: string @@ -255,7 +257,7 @@ export class DecodedMessage { sent, error, contentFallback, - }: Omit) { + }: Omit, 'toBytes'>) { this.id = id this.messageVersion = messageVersion this.senderAddress = senderAddress @@ -283,10 +285,10 @@ export class DecodedMessage { }).finish() } - static async fromBytes( + static async fromBytes( data: Uint8Array, - client: Client - ): Promise { + client: Client + ): Promise>> { const protoVal = proto.DecodedMessage.decode(data) const messageVersion = protoVal.messageVersion @@ -299,7 +301,7 @@ export class DecodedMessage { } const { content, contentType, error, contentFallback } = - await decodeContent(protoVal.contentBytes, client) + await client.decodeContent(protoVal.contentBytes) return new DecodedMessage({ ...protoVal, @@ -317,16 +319,16 @@ export class DecodedMessage { }) } - static fromV1Message( + static fromV1Message( message: MessageV1, - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any + content: T, // eslint-disable-line @typescript-eslint/no-explicit-any contentType: ContentTypeId, contentBytes: Uint8Array, contentTopic: string, - conversation: Conversation, + conversation: Conversation, error?: Error, contentFallback?: string - ): DecodedMessage { + ): DecodedMessage { const { id, senderAddress, recipientAddress, sent } = message if (!senderAddress) { throw new Error('Sender address is required') @@ -347,17 +349,17 @@ export class DecodedMessage { }) } - static fromV2Message( + static fromV2Message( message: MessageV2, content: any, // eslint-disable-line @typescript-eslint/no-explicit-any contentType: ContentTypeId, contentTopic: string, contentBytes: Uint8Array, - conversation: Conversation, + conversation: Conversation, senderAddress: string, error?: Error, contentFallback?: string - ): DecodedMessage { + ): DecodedMessage { const { id, sent } = message return new DecodedMessage({ @@ -376,39 +378,11 @@ export class DecodedMessage { } } -export async function decodeContent(contentBytes: Uint8Array, client: Client) { - const encodedContent = protoContent.EncodedContent.decode(contentBytes) - - if (!encodedContent.type) { - throw new Error('missing content type') - } - - let content: any // eslint-disable-line @typescript-eslint/no-explicit-any - const contentType = new ContentTypeId(encodedContent.type) - let error: Error | undefined - - await decompress(encodedContent, 1000) - - const codec = client.codecFor(contentType) - if (codec) { - content = codec.decode(encodedContent as EncodedContent, client) - } else { - error = new Error('unknown content type ' + contentType) - } - - return { - content, - contentType, - error, - contentFallback: encodedContent.fallback, - } -} - -function conversationReferenceToConversation( +function conversationReferenceToConversation( reference: conversationReference.ConversationReference, - client: Client, - version: DecodedMessage['messageVersion'] -): Conversation { + client: Client, + version: DecodedMessage>['messageVersion'] +): Conversation { if (version === 'v1') { return new ConversationV1( client, diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 20600956..b32f9ffc 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -14,7 +14,7 @@ import Client, { SendOptions, } from '../Client' import { InvitationContext } from '../Invitation' -import { DecodedMessage, MessageV1, MessageV2, decodeContent } from '../Message' +import { DecodedMessage, MessageV1, MessageV2 } from '../Message' import { messageApi, message, @@ -33,12 +33,10 @@ import { sha256 } from '../crypto/encryption' import { buildDecryptV1Request, getResultOrThrow } from '../utils/keystore' import { ContentTypeText } from '../codecs/Text' -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - /** * Conversation represents either a V1 or V2 conversation with a common set of methods. */ -export interface Conversation { +export interface Conversation { conversationVersion: 'v1' | 'v2' /** * The wallet address connected to the client @@ -80,18 +78,18 @@ export interface Conversation { * }) * ``` */ - messages(opts?: ListMessagesOptions): Promise + messages(opts?: ListMessagesOptions): Promise[]> /** * @deprecated */ messagesPaginated( opts?: ListMessagesPaginatedOptions - ): AsyncGenerator + ): AsyncGenerator[]> /** * Takes a XMTP envelope as input and will decrypt and decode it * returning a `DecodedMessage` instance. */ - decodeMessage(env: messageApi.Envelope): Promise + decodeMessage(env: messageApi.Envelope): Promise> /** * Return a `Stream` of new messages in this conversation. * @@ -104,7 +102,7 @@ export interface Conversation { * } * ``` */ - streamMessages(): Promise> + streamMessages(): Promise>> /** * Send a message into the conversation * @@ -113,10 +111,7 @@ export interface Conversation { * await conversation.send('Hello world') // returns a `DecodedMessage` instance * ``` */ - send( - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any - options?: SendOptions - ): Promise + send(content: T, options?: SendOptions): Promise> /** * Return a `PreparedMessage` that has contains the message ID @@ -140,20 +135,20 @@ export interface Conversation { * } * ``` */ - streamEphemeral(): Promise> + streamEphemeral(): Promise>> } /** * ConversationV1 allows you to view, stream, and send messages to/from a peer address */ -export class ConversationV1 implements Conversation { +export class ConversationV1 implements Conversation { conversationVersion = 'v1' as const peerAddress: string createdAt: Date context = undefined - private client: Client + private client: Client - constructor(client: Client, address: string, createdAt: Date) { + constructor(client: Client, address: string, createdAt: Date) { this.peerAddress = utils.getAddress(address) this.client = client this.createdAt = createdAt @@ -177,7 +172,7 @@ export class ConversationV1 implements Conversation { /** * Returns a list of all messages to/from the peerAddress */ - async messages(opts?: ListMessagesOptions): Promise { + async messages(opts?: ListMessagesOptions): Promise[]> { const topic = buildDirectMessageTopic(this.peerAddress, this.client.address) const messages = await this.client.listEnvelopes( topic, @@ -190,7 +185,7 @@ export class ConversationV1 implements Conversation { messagesPaginated( opts?: ListMessagesPaginatedOptions - ): AsyncGenerator { + ): AsyncGenerator[]> { return this.client.listEnvelopesPaginated( this.topic, // This won't be performant once we start supporting a remote keystore @@ -201,7 +196,7 @@ export class ConversationV1 implements Conversation { } // decodeMessage takes an envelope and either returns a `DecodedMessage` or throws if an error occurs - async decodeMessage(env: messageApi.Envelope): Promise { + async decodeMessage(env: messageApi.Envelope): Promise> { if (!env.contentTopic) { throw new Error('Missing content topic') } @@ -265,8 +260,8 @@ export class ConversationV1 implements Conversation { */ streamMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise> { - return Stream.create( + ): Promise>> { + return Stream.create>( this.client, [this.topic], async (env: messageApi.Envelope) => this.decodeMessage(env), @@ -300,8 +295,8 @@ export class ConversationV1 implements Conversation { streamEphemeral( onConnectionLost?: OnConnectionLostCallback - ): Promise> { - return Stream.create( + ): Promise>> { + return Stream.create>( this.client, [this.ephemeralTopic], this.decodeMessage.bind(this), @@ -313,10 +308,7 @@ export class ConversationV1 implements Conversation { /** * Send a message into the conversation. */ - async send( - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any - options?: SendOptions - ): Promise { + async send(content: T, options?: SendOptions): Promise> { let topics: string[] let recipient = await this.client.getUserContact(this.peerAddress) if (!recipient) { @@ -364,14 +356,14 @@ export class ConversationV1 implements Conversation { messages: MessageV1[], topic: string, throwOnError = false - ): Promise { + ): Promise[]> { const responses = ( await this.client.keystore.decryptV1( buildDecryptV1Request(messages, this.client.publicKeyBundle) ) ).responses - const out: DecodedMessage[] = [] + const out: DecodedMessage[] = [] for (let i = 0; i < responses.length; i++) { const result = responses[i] const message = messages[i] @@ -393,9 +385,9 @@ export class ConversationV1 implements Conversation { message: MessageV1, decrypted: Uint8Array, topic: string - ): Promise { + ): Promise> { const { content, contentType, error, contentFallback } = - await decodeContent(decrypted, this.client) + await this.client.decodeContent(decrypted) return DecodedMessage.fromV1Message( message, @@ -430,16 +422,16 @@ export class ConversationV1 implements Conversation { /** * ConversationV2 */ -export class ConversationV2 implements Conversation { +export class ConversationV2 implements Conversation { conversationVersion = 'v2' as const - client: Client + client: Client topic: string peerAddress: string createdAt: Date context?: InvitationContext constructor( - client: Client, + client: Client, topic: string, peerAddress: string, createdAt: Date, @@ -459,7 +451,7 @@ export class ConversationV2 implements Conversation { /** * Returns a list of all messages to/from the peerAddress */ - async messages(opts?: ListMessagesOptions): Promise { + async messages(opts?: ListMessagesOptions): Promise[]> { const messages = await this.client.listEnvelopes( this.topic, this.processEnvelope.bind(this), @@ -471,7 +463,7 @@ export class ConversationV2 implements Conversation { messagesPaginated( opts?: ListMessagesPaginatedOptions - ): AsyncGenerator { + ): AsyncGenerator[]> { return this.client.listEnvelopesPaginated( this.topic, this.decodeMessage.bind(this), @@ -485,8 +477,8 @@ export class ConversationV2 implements Conversation { streamEphemeral( onConnectionLost?: OnConnectionLostCallback - ): Promise> { - return Stream.create( + ): Promise>> { + return Stream.create>( this.client, [this.ephemeralTopic], this.decodeMessage.bind(this), @@ -500,8 +492,8 @@ export class ConversationV2 implements Conversation { */ streamMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise> { - return Stream.create( + ): Promise>> { + return Stream.create>( this.client, [this.topic], this.decodeMessage.bind(this), @@ -513,10 +505,7 @@ export class ConversationV2 implements Conversation { /** * Send a message into the conversation */ - async send( - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any - options?: SendOptions - ): Promise { + async send(content: T, options?: SendOptions): Promise> { const payload = await this.client.encodeContent(content, options) const msg = await this.createMessage(payload, options?.timestamp) @@ -582,12 +571,12 @@ export class ConversationV2 implements Conversation { private async decryptBatch( messages: MessageV2[], throwOnError = false - ): Promise { + ): Promise[]> { const responses = ( await this.client.keystore.decryptV2(this.buildDecryptRequest(messages)) ).responses - const out: DecodedMessage[] = [] + const out: DecodedMessage[] = [] for (let i = 0; i < responses.length; i++) { const result = responses[i] const message = messages[i] @@ -643,7 +632,7 @@ export class ConversationV2 implements Conversation { private async buildDecodedMessage( msg: MessageV2, decrypted: Uint8Array - ): Promise { + ): Promise> { // Decode the decrypted bytes into SignedContent const signed = proto.SignedContent.decode(decrypted) if ( @@ -673,7 +662,7 @@ export class ConversationV2 implements Conversation { ).walletSignatureAddress() const { content, contentType, error, contentFallback } = - await decodeContent(signed.payload, this.client) + await this.client.decodeContent(signed.payload) return DecodedMessage.fromV2Message( msg, @@ -732,7 +721,7 @@ export class ConversationV2 implements Conversation { return MessageV2.create(msg, header, env.message) } - async decodeMessage(env: messageApi.Envelope): Promise { + async decodeMessage(env: messageApi.Envelope): Promise> { if (!env.contentTopic) { throw new Error('Missing content topic') } diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index fcecf711..7198a585 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -28,12 +28,12 @@ const messageHasHeaders = (msg: MessageV1): boolean => { /** * Conversations allows you to view ongoing 1:1 messaging sessions with another wallet */ -export default class Conversations { - private client: Client +export default class Conversations { + private client: Client private v1JobRunner: JobRunner private v2JobRunner: JobRunner - constructor(client: Client) { + constructor(client: Client) { this.client = client this.v1JobRunner = new JobRunner('v1', client.keystore) this.v2JobRunner = new JobRunner('v2', client.keystore) @@ -42,7 +42,7 @@ export default class Conversations { /** * List all conversations with the current wallet found in the network. */ - async list(): Promise { + async list(): Promise[]> { const [v1Convos, v2Convos] = await Promise.all([ this.listV1Conversations(), this.listV2Conversations(), @@ -58,8 +58,8 @@ export default class Conversations { * List all conversations stored in the client cache, which may not include * conversations on the network. */ - async listFromCache(): Promise { - const [v1Convos, v2Convos]: Conversation[][] = await Promise.all([ + async listFromCache(): Promise[]> { + const [v1Convos, v2Convos]: Conversation[][] = await Promise.all([ this.getV1ConversationsFromKeystore(), this.getV2ConversationsFromKeystore(), ]) @@ -69,7 +69,7 @@ export default class Conversations { return conversations } - private async listV1Conversations(): Promise { + private async listV1Conversations(): Promise[]> { return this.v1JobRunner.run(async (latestSeen) => { const seenPeers = await this.getIntroductionPeers({ startTime: latestSeen @@ -98,7 +98,7 @@ export default class Conversations { /** * List all V2 conversations */ - private async listV2Conversations(): Promise { + private async listV2Conversations(): Promise[]> { return this.v2JobRunner.run(async (lastRun) => { // Get all conversations already in the KeyStore const existing = await this.getV2ConversationsFromKeystore() @@ -121,20 +121,20 @@ export default class Conversations { }) } - private async getV2ConversationsFromKeystore(): Promise { + private async getV2ConversationsFromKeystore(): Promise[]> { return (await this.client.keystore.getV2Conversations()).conversations.map( this.conversationReferenceToV2.bind(this) ) } - private async getV1ConversationsFromKeystore(): Promise { + private async getV1ConversationsFromKeystore(): Promise[]> { return (await this.client.keystore.getV1Conversations()).conversations.map( this.conversationReferenceToV1.bind(this) ) } // Called in listV2Conversations and in newConversation - async updateV2Conversations(startTime?: Date): Promise { + async updateV2Conversations(startTime?: Date): Promise[]> { const envelopes = await this.client.listInvitations({ startTime: startTime ? new Date(+startTime - CLOCK_SKEW_OFFSET_MS) @@ -148,7 +148,7 @@ export default class Conversations { private async decodeInvites( envelopes: messageApi.Envelope[], shouldThrow = false - ): Promise { + ): Promise[]> { const { responses } = await this.client.keystore.saveInvites({ requests: envelopes.map((env) => ({ payload: env.message as Uint8Array, @@ -157,7 +157,7 @@ export default class Conversations { })), }) - const out: ConversationV2[] = [] + const out: ConversationV2[] = [] for (const response of responses) { try { out.push(this.saveInviteResponseToConversation(response)) @@ -174,7 +174,7 @@ export default class Conversations { private saveInviteResponseToConversation({ result, error, - }: keystore.SaveInvitesResponse_Response): ConversationV2 { + }: keystore.SaveInvitesResponse_Response): ConversationV2 { if (error || !result || !result.conversation) { throw new Error(`Error from keystore: ${error?.code} ${error?.message}}`) } @@ -183,7 +183,7 @@ export default class Conversations { private conversationReferenceToV2( convoRef: conversationReference.ConversationReference - ): ConversationV2 { + ): ConversationV2 { return new ConversationV2( this.client, convoRef.topic, @@ -195,7 +195,7 @@ export default class Conversations { private conversationReferenceToV1( convoRef: conversationReference.ConversationReference - ): ConversationV1 { + ): ConversationV1 { return new ConversationV1( this.client, convoRef.peerAddress, @@ -210,7 +210,7 @@ export default class Conversations { */ async stream( onConnectionLost?: OnConnectionLostCallback - ): Promise> { + ): Promise>> { const seenPeers: Set = new Set() const introTopic = buildUserIntroTopic(this.client.address) const inviteTopic = buildUserInviteTopic(this.client.address) @@ -249,7 +249,7 @@ export default class Conversations { const topics = [introTopic, inviteTopic] - return Stream.create( + return Stream.create>( this.client, topics, decodeConversation.bind(this), @@ -267,13 +267,13 @@ export default class Conversations { */ async streamAllMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise> { + ): Promise>> { const introTopic = buildUserIntroTopic(this.client.address) const inviteTopic = buildUserInviteTopic(this.client.address) const topics = new Set([introTopic, inviteTopic]) - const convoMap = new Map() + const convoMap = new Map>() for (const conversation of await this.list()) { topics.add(conversation.topic) @@ -282,7 +282,7 @@ export default class Conversations { const decodeMessage = async ( env: messageApi.Envelope - ): Promise => { + ): Promise | DecodedMessage | null> => { const contentTopic = env.contentTopic if (!contentTopic || !env.message) { return null @@ -331,7 +331,10 @@ export default class Conversations { throw new Error('Unknown topic') } - const addConvo = (topic: string, conversation: Conversation): boolean => { + const addConvo = ( + topic: string, + conversation: Conversation + ): boolean => { if (topics.has(topic)) { return false } @@ -340,7 +343,9 @@ export default class Conversations { return true } - const contentTopicUpdater = (msg: Conversation | DecodedMessage | null) => { + const contentTopicUpdater = ( + msg: Conversation | DecodedMessage | null + ) => { // If we have a V1 message from the introTopic, store the conversation in our mapping if (msg instanceof DecodedMessage && msg.contentTopic === introTopic) { const convo = new ConversationV1( @@ -364,7 +369,7 @@ export default class Conversations { return undefined } - const str = await Stream.create( + const str = await Stream.create | Conversation | null>( this.client, Array.from(topics.values()), decodeMessage, @@ -438,7 +443,7 @@ export default class Conversations { async newConversation( peerAddress: string, context?: InvitationContext - ): Promise { + ): Promise> { let contact = await this.client.getUserContact(peerAddress) if (!contact) { throw new Error(`Recipient ${peerAddress} is not on the XMTP network`) @@ -485,7 +490,7 @@ export default class Conversations { } // Define a function for matching V2 conversations - const matcherFn = (convo: Conversation) => + const matcherFn = (convo: Conversation) => convo.peerAddress === peerAddress && isMatchingContext(context, convo.context ?? undefined) @@ -510,7 +515,7 @@ export default class Conversations { private async createV2Convo( recipient: SignedPublicKeyBundle, context?: InvitationContext - ): Promise { + ): Promise> { const timestamp = new Date() const { payload, conversation } = await this.client.keystore.createInvite({ recipient, diff --git a/src/index.ts b/src/index.ts index f9594431..7a1110f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,4 @@ -export { - Message, - DecodedMessage, - decodeContent, - MessageV1, - MessageV2, -} from './Message' +export { Message, DecodedMessage, MessageV1, MessageV2 } from './Message' export { Ciphertext, PublicKey, diff --git a/test/Client.test.ts b/test/Client.test.ts index c3773d22..49ff2317 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -25,7 +25,7 @@ import LocalStoragePonyfill from '../src/keystore/persistence/LocalStoragePonyfi type TestCase = { name: string - newClient: (opts?: Partial) => Promise + newClient: (opts?: Partial) => Promise> } const mockEthRequest = jest.fn() diff --git a/test/Message.test.ts b/test/Message.test.ts index 6ab26a2c..16b7b09f 100644 --- a/test/Message.test.ts +++ b/test/Message.test.ts @@ -248,6 +248,9 @@ describe('Message', function () { sentMessageBytes, aliceClient ) + if (typeof aliceRestoredMessage.content === 'string') { + throw new Error('Expected content to be a PublicKeyBundle') + } expect( equalBytes( aliceRestoredMessage.content?.secp256k1Uncompressed.bytes, diff --git a/test/conversations/Conversation.test.ts b/test/conversations/Conversation.test.ts index 6ef97bea..665e4155 100644 --- a/test/conversations/Conversation.test.ts +++ b/test/conversations/Conversation.test.ts @@ -80,7 +80,7 @@ describe('conversation', () => { expect(messageIds.size).toBe(10) // Test sorting - let lastMessage: DecodedMessage | undefined = undefined + let lastMessage: DecodedMessage | undefined = undefined for await (const page of aliceConversation.messagesPaginated({ direction: SortDirection.SORT_DIRECTION_DESCENDING, })) { @@ -408,7 +408,7 @@ describe('conversation', () => { alice.keystore = aliceKeystore await aliceConvo.send('Hello from Alice') const result = await stream.next() - const msg = result.value as DecodedMessage + const msg = result.value expect(msg.senderAddress).toBe(alice.address) expect(msg.content).toBe('Hello from Alice') await stream.return() @@ -437,11 +437,11 @@ describe('conversation', () => { }) const aliceResult1 = await aliceStream.next() - const aliceMessage1 = aliceResult1.value as DecodedMessage + const aliceMessage1 = aliceResult1.value expect(aliceMessage1.content).toEqual(key) const bobResult1 = await bobStream.next() - const bobMessage1 = bobResult1.value as DecodedMessage + const bobMessage1 = bobResult1.value expect(bobMessage1).toBeTruthy() expect(bobMessage1.error?.message).toBe( 'unknown content type xmtp.test/public-key:1.0' @@ -457,7 +457,7 @@ describe('conversation', () => { contentType: ContentTypeTestKey, }) const bobResult2 = await bobStream.next() - const bobMessage2 = bobResult2.value as DecodedMessage + const bobMessage2 = bobResult2.value expect(bobMessage2.contentType).toBeTruthy() expect(bobMessage2.contentType.sameAs(ContentTypeTestKey)).toBeTruthy() expect(key.equals(bobMessage2.content)).toBeTruthy() @@ -603,7 +603,7 @@ describe('conversation', () => { ) await sleep(100) - const firstMessageFromStream: DecodedMessage = (await stream.next()).value + const firstMessageFromStream = (await stream.next()).value expect(firstMessageFromStream.messageVersion).toBe('v2') expect(firstMessageFromStream.content).toBe('foo') expect(firstMessageFromStream.conversation.context?.conversationId).toBe( @@ -675,11 +675,11 @@ describe('conversation', () => { }) const aliceResult1 = await aliceStream.next() - const aliceMessage1 = aliceResult1.value as DecodedMessage + const aliceMessage1 = aliceResult1.value expect(aliceMessage1.content).toEqual(key) const bobResult1 = await bobStream.next() - const bobMessage1 = bobResult1.value as DecodedMessage + const bobMessage1 = bobResult1.value expect(bobMessage1).toBeTruthy() expect(bobMessage1.error?.message).toBe( 'unknown content type xmtp.test/public-key:1.0' @@ -695,7 +695,7 @@ describe('conversation', () => { contentType: ContentTypeTestKey, }) const bobResult2 = await bobStream.next() - const bobMessage2 = bobResult2.value as DecodedMessage + const bobMessage2 = bobResult2.value expect(bobMessage2.contentType).toBeTruthy() expect(bobMessage2.contentType.sameAs(ContentTypeTestKey)).toBeTruthy() expect(key.equals(bobMessage2.content)).toBeTruthy() From da8daab146acf6339f68de331b8fec363e9009da Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:36:30 -0700 Subject: [PATCH 02/16] chore: remove test function --- src/Client.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 37b77737..5309a2ee 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -865,20 +865,3 @@ async function bootstrapKeystore( } export type ClientReturnType = C extends Client ? T : never - -// async function stronglyTypedClient() { -// const c = await Client.create(null, { -// codecs: [new CompositeCodec(), new TextCodec()], -// }) - -// const convos = await c.conversations.list() -// convos[0].send('hello') -// convos[0].send(123) - -// for await (const msg of await convos[0].streamMessages()) { -// // Let's see what content type this is? -// // -// // -// console.log(msg.content) -// } -// } From 296ef66dc279ba8fedb1b0ac466f2d696a316806 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:37:43 -0700 Subject: [PATCH 03/16] chore: remove unused import --- src/Client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Client.ts b/src/Client.ts index 5309a2ee..c1f58fb0 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -41,7 +41,6 @@ import { } from './keystore/persistence' import { hasMetamaskWithSnaps } from './keystore/snapHelpers' import { version as snapVersion, package as snapPackage } from './snapInfo.json' -import { CompositeCodec } from './codecs/Composite' const { Compression } = proto // eslint-disable @typescript-eslint/explicit-module-boundary-types From c3ab592fd422379253c9984bee153dfa4acc6ad4 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:59:03 -0700 Subject: [PATCH 04/16] chore: add eslint ignore --- src/Client.ts | 3 ++- src/Message.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index c1f58fb0..bd6ea062 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -246,6 +246,7 @@ export function defaultOptions(opts?: Partial): ClientOptions { * Client class initiates connection to the XMTP network. * Should be created with `await Client.create(options)` */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export default class Client { address: string keystore: Keystore @@ -319,7 +320,7 @@ export default class Client { const address = publicKeyBundle.walletSignatureAddress() apiClient.setAuthenticator(new KeystoreAuthenticator(keystore)) const backupClient = await Client.setupBackupClient(address, options.env) - const client = new Client>( + const client = new Client( publicKeyBundle, apiClient, backupClient, diff --git a/src/Message.ts b/src/Message.ts index f149df52..0f6abe6f 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -230,7 +230,7 @@ export class MessageV2 extends MessageBase implements proto.MessageV2 { export type Message = MessageV1 | MessageV2 -export class DecodedMessage { +export class DecodedMessage { id: string messageVersion: 'v1' | 'v2' senderAddress: string From 8f945d6bf7394fca6e0fa857a767328da3cc5673 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 17:04:58 -0700 Subject: [PATCH 05/16] chore: lint --- src/Client.ts | 4 ++-- src/Message.ts | 16 +++------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index bd6ea062..5520d293 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -307,6 +307,8 @@ export default class Client { * @param wallet the wallet as a Signer instance * @param opts specify how to to connect to the network */ + + // eslint-disable-next-line @typescript-eslint/no-explicit-any static async create[]>( wallet: Signer | null, opts?: Partial & { codecs?: U } @@ -746,8 +748,6 @@ export default class Client { } } -export type AnyClient = Client[]> - function createHttpApiClientFromOptions(options: NetworkOptions): ApiClient { const apiUrl = options.apiUrl || ApiUrls[options.env] return new HttpApiClient(apiUrl, { appVersion: options.appVersion }) diff --git a/src/Message.ts b/src/Message.ts index 0f6abe6f..1470132e 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -4,27 +4,17 @@ import { ConversationV2, } from './conversations/Conversation' import type Client from './Client' -import { - message as proto, - content as protoContent, - conversationReference, -} from '@xmtp/proto' +import { message as proto, conversationReference } from '@xmtp/proto' import Long from 'long' import Ciphertext from './crypto/Ciphertext' import { PublicKeyBundle, PublicKey } from './crypto' import { bytesToHex } from './crypto/utils' import { sha256 } from './crypto/encryption' -import { - ContentCodec, - ContentTypeFallback, - ContentTypeId, - EncodedContent, -} from './MessageContent' +import { ContentTypeId } from './MessageContent' import { dateToNs, nsToDate } from './utils' -import { decompress } from './Compression' import { Keystore } from './keystore' import { buildDecryptV1Request, getResultOrThrow } from './utils/keystore' -import { AnyClient, ClientReturnType } from './Client' +import { ClientReturnType } from './Client' const headerBytesAndCiphertext = ( msg: proto.Message From 736c3a6c95a7c53f1413e9dee7fedbfb5e60789b Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 17:23:38 -0700 Subject: [PATCH 06/16] chore: export decodeContent again --- src/Message.ts | 4 ++++ src/index.ts | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Message.ts b/src/Message.ts index 1470132e..8c27a7e4 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -391,3 +391,7 @@ function conversationReferenceToConversation( } throw new Error(`Unknown conversation version ${version}`) } + +export function decodeContent(contentBytes: Uint8Array, client: Client) { + return client.decodeContent(contentBytes) +} diff --git a/src/index.ts b/src/index.ts index 7a1110f3..cbf0326d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ -export { Message, DecodedMessage, MessageV1, MessageV2 } from './Message' +export { + Message, + DecodedMessage, + MessageV1, + MessageV2, + decodeContent, +} from './Message' export { Ciphertext, PublicKey, From 9727f3c1b50f6dde56bea1662d67d9a7293ced12 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 18:35:33 -0700 Subject: [PATCH 07/16] test: add some tests for type assertions --- src/Client.ts | 5 ++--- test/Client.test.ts | 37 +++++++++++++++++++++++++++++++++++++ test/helpers.ts | 8 +++----- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 5520d293..6b7f0f73 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -308,8 +308,7 @@ export default class Client { * @param opts specify how to to connect to the network */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static async create[]>( + static async create[] = []>( wallet: Signer | null, opts?: Partial & { codecs?: U } ): Promise>> { @@ -322,7 +321,7 @@ export default class Client { const address = publicKeyBundle.walletSignatureAddress() apiClient.setAuthenticator(new KeystoreAuthenticator(keystore)) const backupClient = await Client.setupBackupClient(address, options.env) - const client = new Client( + const client = new Client>( publicKeyBundle, apiClient, backupClient, diff --git a/test/Client.test.ts b/test/Client.test.ts index 49ff2317..db446227 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -10,10 +10,13 @@ import { buildUserContactTopic } from '../src/utils' import Client, { ClientOptions } from '../src/Client' import { ApiUrls, + CompositeCodec, Compression, + ContentTypeText, HttpApiClient, InMemoryPersistence, PublishParams, + TextCodec, } from '../src' import NetworkKeyManager from '../src/keystore/providers/NetworkKeyManager' import TopicPersistence from '../src/keystore/persistence/TopicPersistence' @@ -342,6 +345,40 @@ describe('ClientOptions', () => { }) }) + describe('custom codecs', () => { + it('gives type errors when you use the wrong types', async () => { + const client = await Client.create(newWallet()) + const other = await newLocalHostClient() + const convo = await client.conversations.newConversation(other.address) + expect(convo).toBeTruthy() + try { + // Add ts-expect-error so that if we break the type casting someone will notice + // @ts-expect-error + await convo.send(123) + const messages = await convo.messages() + for (const message of messages) { + // Strings don't have this kind of method + // @ts-expect-error + message.toFixed() + } + } catch (e) { + return + } + fail() + }) + + it('allows you to use custom content types', async () => { + const client = await Client.create(newWallet(), { + codecs: [new CompositeCodec()], + }) + const other = await newLocalHostClient() + const convo = await client.conversations.newConversation(other.address) + expect(convo).toBeTruthy() + // This will have a type error if the codecs field isn't being respected + await convo.send({ parts: [{ type: ContentTypeText, content: 'foo' }] }) + }) + }) + describe('Pluggable API client', () => { it('allows you to specify a custom API client factory', async () => { const expectedError = new Error('CustomApiClient') diff --git a/test/helpers.ts b/test/helpers.ts index d697d164..9e46bcaf 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -157,9 +157,7 @@ export class CodecRegistry { // client running against local node running on the host, // see github.com/xmtp/xmtp-node-go/scripts/xmtp-js.sh -export const newLocalHostClient = ( - opts?: Partial -): Promise => +export const newLocalHostClient = (opts?: Partial) => Client.create(newWallet(), { env: 'local', ...opts, @@ -169,14 +167,14 @@ export const newLocalHostClient = ( // with a non-ethers wallet export const newLocalHostClientWithCustomWallet = ( opts?: Partial -): Promise => +) => Client.create(newCustomWallet(), { env: 'local', ...opts, }) // client running against the dev cluster in AWS -export const newDevClient = (opts?: Partial): Promise => +export const newDevClient = (opts?: Partial) => Client.create(newWallet(), { env: 'dev', ...opts, From 7b035681370242ee1474ca542db271fb170bc551 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 18:41:09 -0700 Subject: [PATCH 08/16] test: improve client tests --- test/Client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Client.test.ts b/test/Client.test.ts index db446227..401ca164 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -348,7 +348,7 @@ describe('ClientOptions', () => { describe('custom codecs', () => { it('gives type errors when you use the wrong types', async () => { const client = await Client.create(newWallet()) - const other = await newLocalHostClient() + const other = await Client.create(newWallet()) const convo = await client.conversations.newConversation(other.address) expect(convo).toBeTruthy() try { @@ -371,7 +371,7 @@ describe('ClientOptions', () => { const client = await Client.create(newWallet(), { codecs: [new CompositeCodec()], }) - const other = await newLocalHostClient() + const other = await Client.create(newWallet()) const convo = await client.conversations.newConversation(other.address) expect(convo).toBeTruthy() // This will have a type error if the codecs field isn't being respected From c6da56dbb3f8ee92e68e17f776d411ace92e0f75 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 18:44:39 -0700 Subject: [PATCH 09/16] test: make DecodedMessage default to any --- src/Message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Message.ts b/src/Message.ts index 8c27a7e4..37e4a37b 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -220,7 +220,7 @@ export class MessageV2 extends MessageBase implements proto.MessageV2 { export type Message = MessageV1 | MessageV2 -export class DecodedMessage { +export class DecodedMessage { id: string messageVersion: 'v1' | 'v2' senderAddress: string From ec13522480da77deb3ed561f2d7f3b6208597532 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 18:45:13 -0700 Subject: [PATCH 10/16] build: disable lint warning --- src/Message.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Message.ts b/src/Message.ts index 37e4a37b..c4f585a3 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -220,6 +220,7 @@ export class MessageV2 extends MessageBase implements proto.MessageV2 { export type Message = MessageV1 | MessageV2 +// eslint-disable-next-line @typescript-eslint/no-explicit-any export class DecodedMessage { id: string messageVersion: 'v1' | 'v2' From 1697a9be3f3b5f96c2edfa6e0ceae5d3e6fcacce Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 20:22:47 -0700 Subject: [PATCH 11/16] build: make generic types safer --- src/Client.ts | 11 ++++++----- src/Message.ts | 8 ++++---- src/Stream.ts | 13 +++++++------ src/conversations/Conversation.ts | 20 ++++++++++---------- src/conversations/Conversations.ts | 9 ++++++--- src/index.ts | 4 ++++ src/types/client.ts | 8 ++++++++ test/Client.test.ts | 4 ++-- test/conversations/Conversation.test.ts | 12 ++++++++++-- 9 files changed, 57 insertions(+), 32 deletions(-) create mode 100644 src/types/client.ts diff --git a/src/Client.ts b/src/Client.ts index 6b7f0f73..b2b5a6e7 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -41,6 +41,7 @@ import { } from './keystore/persistence' import { hasMetamaskWithSnaps } from './keystore/snapHelpers' import { version as snapVersion, package as snapPackage } from './snapInfo.json' +import { ExtractDecodedType } from './types/client' const { Compression } = proto // eslint-disable @typescript-eslint/explicit-module-boundary-types @@ -203,8 +204,6 @@ export type ClientOptions = Flatten< PreEventCallbackOptions > -export type ExtractDecodedType = C extends ContentCodec ? T : never - /** * Provide a default client configuration. These settings can be used on their own, or as a starting point for custom configurations * @@ -308,6 +307,7 @@ export default class Client { * @param opts specify how to to connect to the network */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any static async create[] = []>( wallet: Signer | null, opts?: Partial & { codecs?: U } @@ -600,10 +600,13 @@ export default class Client { * messages of the given Content Type */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - registerCodec(codec: ContentCodec): void { + registerCodec>( + codec: Codec + ): Client> { const id = codec.contentType const key = `${id.authorityId}/${id.typeId}` this._codecs.set(key, codec) + return this } /** @@ -862,5 +865,3 @@ async function bootstrapKeystore( } throw new Error('No keystore providers available') } - -export type ClientReturnType = C extends Client ? T : never diff --git a/src/Message.ts b/src/Message.ts index c4f585a3..188541f7 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -14,7 +14,7 @@ import { ContentTypeId } from './MessageContent' import { dateToNs, nsToDate } from './utils' import { Keystore } from './keystore' import { buildDecryptV1Request, getResultOrThrow } from './utils/keystore' -import { ClientReturnType } from './Client' +import { GetMessageContentTypeFromClient } from './types/client' const headerBytesAndCiphertext = ( msg: proto.Message @@ -279,7 +279,7 @@ export class DecodedMessage { static async fromBytes( data: Uint8Array, client: Client - ): Promise>> { + ): Promise> { const protoVal = proto.DecodedMessage.decode(data) const messageVersion = protoVal.messageVersion @@ -342,7 +342,7 @@ export class DecodedMessage { static fromV2Message( message: MessageV2, - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any + content: T, contentType: ContentTypeId, contentTopic: string, contentBytes: Uint8Array, @@ -372,7 +372,7 @@ export class DecodedMessage { function conversationReferenceToConversation( reference: conversationReference.ConversationReference, client: Client, - version: DecodedMessage>['messageVersion'] + version: DecodedMessage['messageVersion'] ): Conversation { if (version === 'v1') { return new ConversationV1( diff --git a/src/Stream.ts b/src/Stream.ts index 1beb2e19..416a8c13 100644 --- a/src/Stream.ts +++ b/src/Stream.ts @@ -16,9 +16,10 @@ export type ContentTopicUpdater = (msg: M) => string[] | undefined * Stream implements an Asynchronous Iterable over messages received from a topic. * As such can be used with constructs like for-await-of, yield*, array destructing, etc. */ -export default class Stream { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default class Stream { topics: string[] - client: Client + client: Client // queue of incoming Waku messages messages: T[] // queue of already pending Promises @@ -32,7 +33,7 @@ export default class Stream { onConnectionLost?: OnConnectionLostCallback constructor( - client: Client, + client: Client, topics: string[], decoder: MessageDecoder, contentTopicUpdater?: ContentTopicUpdater, @@ -100,13 +101,13 @@ export default class Stream { ) } - static async create( - client: Client, + static async create( + client: Client, topics: string[], decoder: MessageDecoder, contentTopicUpdater?: ContentTopicUpdater, onConnectionLost?: OnConnectionLostCallback - ): Promise> { + ): Promise> { const stream = new Stream( client, topics, diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index b32f9ffc..9b7116e5 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -102,7 +102,7 @@ export interface Conversation { * } * ``` */ - streamMessages(): Promise>> + streamMessages(): Promise, T>> /** * Send a message into the conversation * @@ -135,7 +135,7 @@ export interface Conversation { * } * ``` */ - streamEphemeral(): Promise>> + streamEphemeral(): Promise, T>> } /** @@ -260,8 +260,8 @@ export class ConversationV1 implements Conversation { */ streamMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise>> { - return Stream.create>( + ): Promise, T>> { + return Stream.create, T>( this.client, [this.topic], async (env: messageApi.Envelope) => this.decodeMessage(env), @@ -295,8 +295,8 @@ export class ConversationV1 implements Conversation { streamEphemeral( onConnectionLost?: OnConnectionLostCallback - ): Promise>> { - return Stream.create>( + ): Promise, T>> { + return Stream.create, T>( this.client, [this.ephemeralTopic], this.decodeMessage.bind(this), @@ -477,8 +477,8 @@ export class ConversationV2 implements Conversation { streamEphemeral( onConnectionLost?: OnConnectionLostCallback - ): Promise>> { - return Stream.create>( + ): Promise, T>> { + return Stream.create, T>( this.client, [this.ephemeralTopic], this.decodeMessage.bind(this), @@ -492,8 +492,8 @@ export class ConversationV2 implements Conversation { */ streamMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise>> { - return Stream.create>( + ): Promise, T>> { + return Stream.create, T>( this.client, [this.topic], this.decodeMessage.bind(this), diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index 7198a585..dba2a3c1 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -210,7 +210,7 @@ export default class Conversations { */ async stream( onConnectionLost?: OnConnectionLostCallback - ): Promise>> { + ): Promise, T>> { const seenPeers: Set = new Set() const introTopic = buildUserIntroTopic(this.client.address) const inviteTopic = buildUserInviteTopic(this.client.address) @@ -249,7 +249,7 @@ export default class Conversations { const topics = [introTopic, inviteTopic] - return Stream.create>( + return Stream.create, T>( this.client, topics, decodeConversation.bind(this), @@ -369,7 +369,10 @@ export default class Conversations { return undefined } - const str = await Stream.create | Conversation | null>( + const str = await Stream.create< + DecodedMessage | Conversation | null, + T + >( this.client, Array.from(topics.values()), decodeMessage, diff --git a/src/index.ts b/src/index.ts index cbf0326d..196ad7bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -108,3 +108,7 @@ export { } from './keystore/persistence' export { InvitationContext, SealedInvitation } from './Invitation' export { decodeContactBundle } from './ContactBundle' +export type { + GetMessageContentTypeFromClient, + ExtractDecodedType, +} from './types/client' diff --git a/src/types/client.ts b/src/types/client.ts new file mode 100644 index 00000000..8adb9c59 --- /dev/null +++ b/src/types/client.ts @@ -0,0 +1,8 @@ +import type Client from '../Client' +import type { ContentCodec } from '../MessageContent' + +export type GetMessageContentTypeFromClient = C extends Client + ? T + : never + +export type ExtractDecodedType = C extends ContentCodec ? T : never diff --git a/test/Client.test.ts b/test/Client.test.ts index 401ca164..88b63c0f 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -347,8 +347,8 @@ describe('ClientOptions', () => { describe('custom codecs', () => { it('gives type errors when you use the wrong types', async () => { - const client = await Client.create(newWallet()) - const other = await Client.create(newWallet()) + const client = await Client.create(newWallet(), { env: 'local' }) + const other = await Client.create(newWallet(), { env: 'local' }) const convo = await client.conversations.newConversation(other.address) expect(convo).toBeTruthy() try { diff --git a/test/conversations/Conversation.test.ts b/test/conversations/Conversation.test.ts index 665e4155..5f5c5166 100644 --- a/test/conversations/Conversation.test.ts +++ b/test/conversations/Conversation.test.ts @@ -10,8 +10,8 @@ import { ContentTypeTestKey, TestKeyCodec } from '../ContentTypeTestKey' import { content as proto } from '@xmtp/proto' describe('conversation', () => { - let alice: Client - let bob: Client + let alice: Client + let bob: Client describe('v1', () => { beforeEach(async () => { @@ -425,6 +425,7 @@ describe('conversation', () => { // alice doesn't recognize the type await expect( + // @ts-expect-error aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -432,6 +433,7 @@ describe('conversation', () => { // bob doesn't recognize the type alice.registerCodec(new TestKeyCodec()) + // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -453,6 +455,7 @@ describe('conversation', () => { // both recognize the type bob.registerCodec(new TestKeyCodec()) + // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -467,6 +470,7 @@ describe('conversation', () => { ...ContentTypeTestKey, versionMajor: 2, }) + // @ts-expect-error expect(aliceConvo.send(key, { contentType: type2 })).rejects.toThrow( 'unknown content type xmtp.test/public-key:2.0' ) @@ -663,6 +667,7 @@ describe('conversation', () => { // alice doesn't recognize the type expect( + // @ts-expect-error aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -670,6 +675,7 @@ describe('conversation', () => { // bob doesn't recognize the type alice.registerCodec(new TestKeyCodec()) + // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -691,6 +697,7 @@ describe('conversation', () => { // both recognize the type bob.registerCodec(new TestKeyCodec()) + // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -705,6 +712,7 @@ describe('conversation', () => { ...ContentTypeTestKey, versionMajor: 2, }) + // @ts-expect-error expect(aliceConvo.send(key, { contentType: type2 })).rejects.toThrow( 'unknown content type xmtp.test/public-key:2.0' ) From b3836f76ccf35903d8845dd6940e4743c9a1bc6d Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 20:29:20 -0700 Subject: [PATCH 12/16] chore: default conversation types to any --- src/conversations/Conversation.ts | 3 ++- src/conversations/Conversations.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 9b7116e5..8cccaca9 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -36,7 +36,8 @@ import { ContentTypeText } from '../codecs/Text' /** * Conversation represents either a V1 or V2 conversation with a common set of methods. */ -export interface Conversation { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface Conversation { conversationVersion: 'v1' | 'v2' /** * The wallet address connected to the client diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index dba2a3c1..92e8d6fb 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -28,7 +28,8 @@ const messageHasHeaders = (msg: MessageV1): boolean => { /** * Conversations allows you to view ongoing 1:1 messaging sessions with another wallet */ -export default class Conversations { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default class Conversations { private client: Client private v1JobRunner: JobRunner private v2JobRunner: JobRunner From 28e63bfcf7ca3f885f6aa5968a59ff28d422d0f5 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 12 Sep 2023 20:46:46 -0700 Subject: [PATCH 13/16] build: disallow sending undefined, but do return it as possible type --- src/Client.ts | 13 ++++++------- src/conversations/Conversation.ts | 15 ++++++++++++--- test/Message.test.ts | 5 ++++- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index b2b5a6e7..0bc4fe3d 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -311,7 +311,9 @@ export default class Client { static async create[] = []>( wallet: Signer | null, opts?: Partial & { codecs?: U } - ): Promise>> { + ): Promise< + Client | undefined> + > { const options = defaultOptions(opts) const apiClient = options.apiClientFactory(options) const keystore = await bootstrapKeystore(options, apiClient, wallet) @@ -321,12 +323,9 @@ export default class Client { const address = publicKeyBundle.walletSignatureAddress() apiClient.setAuthenticator(new KeystoreAuthenticator(keystore)) const backupClient = await Client.setupBackupClient(address, options.env) - const client = new Client>( - publicKeyBundle, - apiClient, - backupClient, - keystore - ) + const client = new Client< + ExtractDecodedType<[...U, TextCodec][number]> | undefined + >(publicKeyBundle, apiClient, backupClient, keystore) await client.init(options) return client } diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 8cccaca9..1d501cc7 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -112,7 +112,10 @@ export interface Conversation { * await conversation.send('Hello world') // returns a `DecodedMessage` instance * ``` */ - send(content: T, options?: SendOptions): Promise> + send( + content: Exclude, + options?: SendOptions + ): Promise> /** * Return a `PreparedMessage` that has contains the message ID @@ -309,7 +312,10 @@ export class ConversationV1 implements Conversation { /** * Send a message into the conversation. */ - async send(content: T, options?: SendOptions): Promise> { + async send( + content: Exclude, + options?: SendOptions + ): Promise> { let topics: string[] let recipient = await this.client.getUserContact(this.peerAddress) if (!recipient) { @@ -506,7 +512,10 @@ export class ConversationV2 implements Conversation { /** * Send a message into the conversation */ - async send(content: T, options?: SendOptions): Promise> { + async send( + content: Exclude, + options?: SendOptions + ): Promise> { const payload = await this.client.encodeContent(content, options) const msg = await this.createMessage(payload, options?.timestamp) diff --git a/test/Message.test.ts b/test/Message.test.ts index 16b7b09f..0f95b802 100644 --- a/test/Message.test.ts +++ b/test/Message.test.ts @@ -248,7 +248,10 @@ describe('Message', function () { sentMessageBytes, aliceClient ) - if (typeof aliceRestoredMessage.content === 'string') { + if ( + typeof aliceRestoredMessage.content === 'string' || + !aliceRestoredMessage.content + ) { throw new Error('Expected content to be a PublicKeyBundle') } expect( From 8ab816260943d4a20be3b0948cfb70bf76f7ce91 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 13 Sep 2023 08:07:18 -0700 Subject: [PATCH 14/16] fix: fix a bug that the types found --- bench/encode.ts | 2 +- test/Client.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bench/encode.ts b/bench/encode.ts index 42eee2ba..7c52b995 100644 --- a/bench/encode.ts +++ b/bench/encode.ts @@ -17,7 +17,7 @@ const encodeV1 = () => { const alice = await Client.create(newWallet(), { env: 'local' }) const bobKeys = (await newPrivateKeyBundle()).getPublicKeyBundle() - const message = randomBytes(size) + const message = randomBytes(size).toString() const timestamp = new Date() // The returned function is the actual benchmark. Everything above is setup diff --git a/test/Client.test.ts b/test/Client.test.ts index 88b63c0f..947a8ac0 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -201,7 +201,9 @@ describe('encodeContent', () => { describe('canMessage', () => { it('can confirm a user is on the network statically', async () => { - const registeredClient = await newLocalHostClient() + const registeredClient = await newLocalHostClient({ + codecs: [new TextCodec()], + }) await waitForUserContact(registeredClient, registeredClient) const canMessageRegisteredClient = await Client.canMessage( registeredClient.address, From 90233f680ae2398e6d984ab7a8d4e4c4892fc55c Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 13 Sep 2023 15:51:34 -0700 Subject: [PATCH 15/16] chore: rename types --- src/Client.ts | 25 +++++---- src/Message.ts | 41 +++++++------- src/conversations/Conversation.ts | 88 +++++++++++++++++------------- src/conversations/Conversations.ts | 73 ++++++++++++++----------- 4 files changed, 129 insertions(+), 98 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 0bc4fe3d..592359a7 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -246,7 +246,7 @@ export function defaultOptions(opts?: Partial): ClientOptions { * Should be created with `await Client.create(options)` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export default class Client { +export default class Client { address: string keystore: Keystore apiClient: ApiClient @@ -258,7 +258,7 @@ export default class Client { > // addresses and key bundles that we have witnessed private _backupClient: BackupClient - private readonly _conversations: Conversations + private readonly _conversations: Conversations // eslint-disable-next-line @typescript-eslint/no-explicit-any private _codecs: Map> private _maxContentSize: number @@ -288,7 +288,7 @@ export default class Client { /** * @type {Conversations} */ - get conversations(): Conversations { + get conversations(): Conversations { return this._conversations } @@ -308,11 +308,13 @@ export default class Client { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - static async create[] = []>( + static async create[] = []>( wallet: Signer | null, - opts?: Partial & { codecs?: U } + opts?: Partial & { codecs?: ContentCodecs } ): Promise< - Client | undefined> + Client< + ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined + > > { const options = defaultOptions(opts) const apiClient = options.apiClientFactory(options) @@ -324,7 +326,7 @@ export default class Client { apiClient.setAuthenticator(new KeystoreAuthenticator(keystore)) const backupClient = await Client.setupBackupClient(address, options.env) const client = new Client< - ExtractDecodedType<[...U, TextCodec][number]> | undefined + ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined >(publicKeyBundle, apiClient, backupClient, keystore) await client.init(options) return client @@ -601,7 +603,7 @@ export default class Client { // eslint-disable-next-line @typescript-eslint/no-explicit-any registerCodec>( codec: Codec - ): Client> { + ): Client> { const id = codec.contentType const key = `${id.authorityId}/${id.typeId}` this._codecs.set(key, codec) @@ -629,7 +631,10 @@ export default class Client { * Convert arbitrary content into a serialized `EncodedContent` instance * with the given options */ - async encodeContent(content: T, options?: SendOptions): Promise { + async encodeContent( + content: ContentTypes, + options?: SendOptions + ): Promise { const contentType = options?.contentType || ContentTypeText const codec = this.codecFor(contentType) if (!codec) { @@ -649,7 +654,7 @@ export default class Client { } async decodeContent(contentBytes: Uint8Array): Promise<{ - content: T + content: ContentTypes contentType: ContentTypeId error?: Error contentFallback?: string diff --git a/src/Message.ts b/src/Message.ts index 188541f7..f4a23bbf 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -221,16 +221,16 @@ export class MessageV2 extends MessageBase implements proto.MessageV2 { export type Message = MessageV1 | MessageV2 // eslint-disable-next-line @typescript-eslint/no-explicit-any -export class DecodedMessage { +export class DecodedMessage { id: string messageVersion: 'v1' | 'v2' senderAddress: string recipientAddress?: string sent: Date contentTopic: string - conversation: Conversation + conversation: Conversation contentType: ContentTypeId - content: T // eslint-disable-line @typescript-eslint/no-explicit-any + content: ContentTypes error?: Error contentBytes: Uint8Array contentFallback?: string @@ -248,7 +248,7 @@ export class DecodedMessage { sent, error, contentFallback, - }: Omit, 'toBytes'>) { + }: Omit, 'toBytes'>) { this.id = id this.messageVersion = messageVersion this.senderAddress = senderAddress @@ -276,10 +276,10 @@ export class DecodedMessage { }).finish() } - static async fromBytes( + static async fromBytes( data: Uint8Array, - client: Client - ): Promise> { + client: Client + ): Promise> { const protoVal = proto.DecodedMessage.decode(data) const messageVersion = protoVal.messageVersion @@ -310,16 +310,16 @@ export class DecodedMessage { }) } - static fromV1Message( + static fromV1Message( message: MessageV1, - content: T, // eslint-disable-line @typescript-eslint/no-explicit-any + content: ContentTypes, contentType: ContentTypeId, contentBytes: Uint8Array, contentTopic: string, - conversation: Conversation, + conversation: Conversation, error?: Error, contentFallback?: string - ): DecodedMessage { + ): DecodedMessage { const { id, senderAddress, recipientAddress, sent } = message if (!senderAddress) { throw new Error('Sender address is required') @@ -340,17 +340,17 @@ export class DecodedMessage { }) } - static fromV2Message( + static fromV2Message( message: MessageV2, - content: T, + content: ContentTypes, contentType: ContentTypeId, contentTopic: string, contentBytes: Uint8Array, - conversation: Conversation, + conversation: Conversation, senderAddress: string, error?: Error, contentFallback?: string - ): DecodedMessage { + ): DecodedMessage { const { id, sent } = message return new DecodedMessage({ @@ -369,11 +369,11 @@ export class DecodedMessage { } } -function conversationReferenceToConversation( +function conversationReferenceToConversation( reference: conversationReference.ConversationReference, - client: Client, + client: Client, version: DecodedMessage['messageVersion'] -): Conversation { +): Conversation { if (version === 'v1') { return new ConversationV1( client, @@ -393,6 +393,9 @@ function conversationReferenceToConversation( throw new Error(`Unknown conversation version ${version}`) } -export function decodeContent(contentBytes: Uint8Array, client: Client) { +export function decodeContent( + contentBytes: Uint8Array, + client: Client +) { return client.decodeContent(contentBytes) } diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 1d501cc7..e8380411 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -37,7 +37,7 @@ import { ContentTypeText } from '../codecs/Text' * Conversation represents either a V1 or V2 conversation with a common set of methods. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface Conversation { +export interface Conversation { conversationVersion: 'v1' | 'v2' /** * The wallet address connected to the client @@ -79,18 +79,18 @@ export interface Conversation { * }) * ``` */ - messages(opts?: ListMessagesOptions): Promise[]> + messages(opts?: ListMessagesOptions): Promise[]> /** * @deprecated */ messagesPaginated( opts?: ListMessagesPaginatedOptions - ): AsyncGenerator[]> + ): AsyncGenerator[]> /** * Takes a XMTP envelope as input and will decrypt and decode it * returning a `DecodedMessage` instance. */ - decodeMessage(env: messageApi.Envelope): Promise> + decodeMessage(env: messageApi.Envelope): Promise> /** * Return a `Stream` of new messages in this conversation. * @@ -103,7 +103,7 @@ export interface Conversation { * } * ``` */ - streamMessages(): Promise, T>> + streamMessages(): Promise, ContentTypes>> /** * Send a message into the conversation * @@ -113,9 +113,9 @@ export interface Conversation { * ``` */ send( - content: Exclude, + content: Exclude, options?: SendOptions - ): Promise> + ): Promise> /** * Return a `PreparedMessage` that has contains the message ID @@ -139,20 +139,22 @@ export interface Conversation { * } * ``` */ - streamEphemeral(): Promise, T>> + streamEphemeral(): Promise, ContentTypes>> } /** * ConversationV1 allows you to view, stream, and send messages to/from a peer address */ -export class ConversationV1 implements Conversation { +export class ConversationV1 + implements Conversation +{ conversationVersion = 'v1' as const peerAddress: string createdAt: Date context = undefined - private client: Client + private client: Client - constructor(client: Client, address: string, createdAt: Date) { + constructor(client: Client, address: string, createdAt: Date) { this.peerAddress = utils.getAddress(address) this.client = client this.createdAt = createdAt @@ -176,7 +178,9 @@ export class ConversationV1 implements Conversation { /** * Returns a list of all messages to/from the peerAddress */ - async messages(opts?: ListMessagesOptions): Promise[]> { + async messages( + opts?: ListMessagesOptions + ): Promise[]> { const topic = buildDirectMessageTopic(this.peerAddress, this.client.address) const messages = await this.client.listEnvelopes( topic, @@ -189,7 +193,7 @@ export class ConversationV1 implements Conversation { messagesPaginated( opts?: ListMessagesPaginatedOptions - ): AsyncGenerator[]> { + ): AsyncGenerator[]> { return this.client.listEnvelopesPaginated( this.topic, // This won't be performant once we start supporting a remote keystore @@ -200,7 +204,9 @@ export class ConversationV1 implements Conversation { } // decodeMessage takes an envelope and either returns a `DecodedMessage` or throws if an error occurs - async decodeMessage(env: messageApi.Envelope): Promise> { + async decodeMessage( + env: messageApi.Envelope + ): Promise> { if (!env.contentTopic) { throw new Error('Missing content topic') } @@ -264,8 +270,8 @@ export class ConversationV1 implements Conversation { */ streamMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise, T>> { - return Stream.create, T>( + ): Promise, ContentTypes>> { + return Stream.create, ContentTypes>( this.client, [this.topic], async (env: messageApi.Envelope) => this.decodeMessage(env), @@ -299,8 +305,8 @@ export class ConversationV1 implements Conversation { streamEphemeral( onConnectionLost?: OnConnectionLostCallback - ): Promise, T>> { - return Stream.create, T>( + ): Promise, ContentTypes>> { + return Stream.create, ContentTypes>( this.client, [this.ephemeralTopic], this.decodeMessage.bind(this), @@ -313,9 +319,9 @@ export class ConversationV1 implements Conversation { * Send a message into the conversation. */ async send( - content: Exclude, + content: Exclude, options?: SendOptions - ): Promise> { + ): Promise> { let topics: string[] let recipient = await this.client.getUserContact(this.peerAddress) if (!recipient) { @@ -363,14 +369,14 @@ export class ConversationV1 implements Conversation { messages: MessageV1[], topic: string, throwOnError = false - ): Promise[]> { + ): Promise[]> { const responses = ( await this.client.keystore.decryptV1( buildDecryptV1Request(messages, this.client.publicKeyBundle) ) ).responses - const out: DecodedMessage[] = [] + const out: DecodedMessage[] = [] for (let i = 0; i < responses.length; i++) { const result = responses[i] const message = messages[i] @@ -392,7 +398,7 @@ export class ConversationV1 implements Conversation { message: MessageV1, decrypted: Uint8Array, topic: string - ): Promise> { + ): Promise> { const { content, contentType, error, contentFallback } = await this.client.decodeContent(decrypted) @@ -429,16 +435,18 @@ export class ConversationV1 implements Conversation { /** * ConversationV2 */ -export class ConversationV2 implements Conversation { +export class ConversationV2 + implements Conversation +{ conversationVersion = 'v2' as const - client: Client + client: Client topic: string peerAddress: string createdAt: Date context?: InvitationContext constructor( - client: Client, + client: Client, topic: string, peerAddress: string, createdAt: Date, @@ -458,7 +466,9 @@ export class ConversationV2 implements Conversation { /** * Returns a list of all messages to/from the peerAddress */ - async messages(opts?: ListMessagesOptions): Promise[]> { + async messages( + opts?: ListMessagesOptions + ): Promise[]> { const messages = await this.client.listEnvelopes( this.topic, this.processEnvelope.bind(this), @@ -470,7 +480,7 @@ export class ConversationV2 implements Conversation { messagesPaginated( opts?: ListMessagesPaginatedOptions - ): AsyncGenerator[]> { + ): AsyncGenerator[]> { return this.client.listEnvelopesPaginated( this.topic, this.decodeMessage.bind(this), @@ -484,8 +494,8 @@ export class ConversationV2 implements Conversation { streamEphemeral( onConnectionLost?: OnConnectionLostCallback - ): Promise, T>> { - return Stream.create, T>( + ): Promise, ContentTypes>> { + return Stream.create, ContentTypes>( this.client, [this.ephemeralTopic], this.decodeMessage.bind(this), @@ -499,8 +509,8 @@ export class ConversationV2 implements Conversation { */ streamMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise, T>> { - return Stream.create, T>( + ): Promise, ContentTypes>> { + return Stream.create, ContentTypes>( this.client, [this.topic], this.decodeMessage.bind(this), @@ -513,9 +523,9 @@ export class ConversationV2 implements Conversation { * Send a message into the conversation */ async send( - content: Exclude, + content: Exclude, options?: SendOptions - ): Promise> { + ): Promise> { const payload = await this.client.encodeContent(content, options) const msg = await this.createMessage(payload, options?.timestamp) @@ -581,12 +591,12 @@ export class ConversationV2 implements Conversation { private async decryptBatch( messages: MessageV2[], throwOnError = false - ): Promise[]> { + ): Promise[]> { const responses = ( await this.client.keystore.decryptV2(this.buildDecryptRequest(messages)) ).responses - const out: DecodedMessage[] = [] + const out: DecodedMessage[] = [] for (let i = 0; i < responses.length; i++) { const result = responses[i] const message = messages[i] @@ -642,7 +652,7 @@ export class ConversationV2 implements Conversation { private async buildDecodedMessage( msg: MessageV2, decrypted: Uint8Array - ): Promise> { + ): Promise> { // Decode the decrypted bytes into SignedContent const signed = proto.SignedContent.decode(decrypted) if ( @@ -731,7 +741,9 @@ export class ConversationV2 implements Conversation { return MessageV2.create(msg, header, env.message) } - async decodeMessage(env: messageApi.Envelope): Promise> { + async decodeMessage( + env: messageApi.Envelope + ): Promise> { if (!env.contentTopic) { throw new Error('Missing content topic') } diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index b70a7d6c..1423daf3 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -29,12 +29,12 @@ const messageHasHeaders = (msg: MessageV1): boolean => { * Conversations allows you to view ongoing 1:1 messaging sessions with another wallet */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export default class Conversations { - private client: Client +export default class Conversations { + private client: Client private v1JobRunner: JobRunner private v2JobRunner: JobRunner - constructor(client: Client) { + constructor(client: Client) { this.client = client this.v1JobRunner = new JobRunner('v1', client.keystore) this.v2JobRunner = new JobRunner('v2', client.keystore) @@ -43,7 +43,7 @@ export default class Conversations { /** * List all conversations with the current wallet found in the network. */ - async list(): Promise[]> { + async list(): Promise[]> { const [v1Convos, v2Convos] = await Promise.all([ this.listV1Conversations(), this.listV2Conversations(), @@ -59,18 +59,19 @@ export default class Conversations { * List all conversations stored in the client cache, which may not include * conversations on the network. */ - async listFromCache(): Promise[]> { - const [v1Convos, v2Convos]: Conversation[][] = await Promise.all([ - this.getV1ConversationsFromKeystore(), - this.getV2ConversationsFromKeystore(), - ]) + async listFromCache(): Promise[]> { + const [v1Convos, v2Convos]: Conversation[][] = + await Promise.all([ + this.getV1ConversationsFromKeystore(), + this.getV2ConversationsFromKeystore(), + ]) const conversations = v1Convos.concat(v2Convos) conversations.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) return conversations } - private async listV1Conversations(): Promise[]> { + private async listV1Conversations(): Promise[]> { return this.v1JobRunner.run(async (latestSeen) => { const seenPeers = await this.getIntroductionPeers({ startTime: latestSeen @@ -99,7 +100,7 @@ export default class Conversations { /** * List all V2 conversations */ - private async listV2Conversations(): Promise[]> { + private async listV2Conversations(): Promise[]> { return this.v2JobRunner.run(async (lastRun) => { // Get all conversations already in the KeyStore const existing = await this.getV2ConversationsFromKeystore() @@ -122,20 +123,26 @@ export default class Conversations { }) } - private async getV2ConversationsFromKeystore(): Promise[]> { + private async getV2ConversationsFromKeystore(): Promise< + ConversationV2[] + > { return (await this.client.keystore.getV2Conversations()).conversations.map( this.conversationReferenceToV2.bind(this) ) } - private async getV1ConversationsFromKeystore(): Promise[]> { + private async getV1ConversationsFromKeystore(): Promise< + ConversationV1[] + > { return (await this.client.keystore.getV1Conversations()).conversations.map( this.conversationReferenceToV1.bind(this) ) } // Called in listV2Conversations and in newConversation - async updateV2Conversations(startTime?: Date): Promise[]> { + async updateV2Conversations( + startTime?: Date + ): Promise[]> { const envelopes = await this.client.listInvitations({ startTime: startTime ? new Date(+startTime - CLOCK_SKEW_OFFSET_MS) @@ -149,7 +156,7 @@ export default class Conversations { private async decodeInvites( envelopes: messageApi.Envelope[], shouldThrow = false - ): Promise[]> { + ): Promise[]> { const { responses } = await this.client.keystore.saveInvites({ requests: envelopes.map((env) => ({ payload: env.message as Uint8Array, @@ -158,7 +165,7 @@ export default class Conversations { })), }) - const out: ConversationV2[] = [] + const out: ConversationV2[] = [] for (const response of responses) { try { out.push(this.saveInviteResponseToConversation(response)) @@ -175,7 +182,7 @@ export default class Conversations { private saveInviteResponseToConversation({ result, error, - }: keystore.SaveInvitesResponse_Response): ConversationV2 { + }: keystore.SaveInvitesResponse_Response): ConversationV2 { if (error || !result || !result.conversation) { throw new Error(`Error from keystore: ${error?.code} ${error?.message}}`) } @@ -184,7 +191,7 @@ export default class Conversations { private conversationReferenceToV2( convoRef: conversationReference.ConversationReference - ): ConversationV2 { + ): ConversationV2 { return new ConversationV2( this.client, convoRef.topic, @@ -196,7 +203,7 @@ export default class Conversations { private conversationReferenceToV1( convoRef: conversationReference.ConversationReference - ): ConversationV1 { + ): ConversationV1 { return new ConversationV1( this.client, convoRef.peerAddress, @@ -211,7 +218,7 @@ export default class Conversations { */ async stream( onConnectionLost?: OnConnectionLostCallback - ): Promise, T>> { + ): Promise, ContentTypes>> { const seenPeers: Set = new Set() const introTopic = buildUserIntroTopic(this.client.address) const inviteTopic = buildUserInviteTopic(this.client.address) @@ -250,7 +257,7 @@ export default class Conversations { const topics = [introTopic, inviteTopic] - return Stream.create, T>( + return Stream.create, ContentTypes>( this.client, topics, decodeConversation.bind(this), @@ -268,13 +275,13 @@ export default class Conversations { */ async streamAllMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise>> { + ): Promise>> { const introTopic = buildUserIntroTopic(this.client.address) const inviteTopic = buildUserInviteTopic(this.client.address) const topics = new Set([introTopic, inviteTopic]) - const convoMap = new Map>() + const convoMap = new Map>() for (const conversation of await this.list()) { topics.add(conversation.topic) @@ -283,7 +290,9 @@ export default class Conversations { const decodeMessage = async ( env: messageApi.Envelope - ): Promise | DecodedMessage | null> => { + ): Promise< + Conversation | DecodedMessage | null + > => { const contentTopic = env.contentTopic if (!contentTopic || !env.message) { return null @@ -334,7 +343,7 @@ export default class Conversations { const addConvo = ( topic: string, - conversation: Conversation + conversation: Conversation ): boolean => { if (topics.has(topic)) { return false @@ -345,7 +354,7 @@ export default class Conversations { } const contentTopicUpdater = ( - msg: Conversation | DecodedMessage | null + msg: Conversation | DecodedMessage | null ) => { // If we have a V1 message from the introTopic, store the conversation in our mapping if (msg instanceof DecodedMessage && msg.contentTopic === introTopic) { @@ -371,8 +380,8 @@ export default class Conversations { } const str = await Stream.create< - DecodedMessage | Conversation | null, - T + DecodedMessage | Conversation | null, + ContentTypes >( this.client, Array.from(topics.values()), @@ -400,6 +409,8 @@ export default class Conversations { // Generators by default need to wait until the next yield to return. // In this case, that's only when the next message arrives...which could be a long time gen.return = async () => { + // Returning the stream will cause the iteration to end inside the generator + // The generator will then return on its own await str?.return() return { value: undefined, done: true } } @@ -457,7 +468,7 @@ export default class Conversations { async newConversation( peerAddress: string, context?: InvitationContext - ): Promise> { + ): Promise> { let contact = await this.client.getUserContact(peerAddress) if (!contact) { throw new Error(`Recipient ${peerAddress} is not on the XMTP network`) @@ -504,7 +515,7 @@ export default class Conversations { } // Define a function for matching V2 conversations - const matcherFn = (convo: Conversation) => + const matcherFn = (convo: Conversation) => convo.peerAddress === peerAddress && isMatchingContext(context, convo.context ?? undefined) @@ -529,7 +540,7 @@ export default class Conversations { private async createV2Convo( recipient: SignedPublicKeyBundle, context?: InvitationContext - ): Promise> { + ): Promise> { const timestamp = new Date() const { payload, conversation } = await this.client.keystore.createInvite({ recipient, From 4d301604236ffc77e15fe0ce67ab3c58bd55c868 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 13 Sep 2023 16:04:18 -0700 Subject: [PATCH 16/16] chore: remove unused import --- src/Message.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Message.ts b/src/Message.ts index f4a23bbf..f98a41e8 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -14,7 +14,6 @@ import { ContentTypeId } from './MessageContent' import { dateToNs, nsToDate } from './utils' import { Keystore } from './keystore' import { buildDecryptV1Request, getResultOrThrow } from './utils/keystore' -import { GetMessageContentTypeFromClient } from './types/client' const headerBytesAndCiphertext = ( msg: proto.Message