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/src/Client.ts b/src/Client.ts index 7e4e1912..592359a7 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 { ExtractDecodedType } from './types/client' const { Compression } = proto // eslint-disable @typescript-eslint/explicit-module-boundary-types @@ -244,7 +245,8 @@ 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 { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default class Client { address: string keystore: Keystore apiClient: ApiClient @@ -256,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 @@ -286,7 +288,7 @@ export default class Client { /** * @type {Conversations} */ - get conversations(): Conversations { + get conversations(): Conversations { return this._conversations } @@ -304,10 +306,16 @@ 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( + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static async create[] = []>( wallet: Signer | null, - opts?: Partial - ): Promise { + opts?: Partial & { codecs?: ContentCodecs } + ): Promise< + Client< + ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined + > + > { const options = defaultOptions(opts) const apiClient = options.apiClientFactory(options) const keystore = await bootstrapKeystore(options, apiClient, wallet) @@ -317,12 +325,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<[...ContentCodecs, TextCodec][number]> | undefined + >(publicKeyBundle, apiClient, backupClient, keystore) await client.init(options) return client } @@ -337,9 +342,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() @@ -596,10 +601,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 } /** @@ -624,8 +632,7 @@ export default class Client { * with the given options */ async encodeContent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - content: any, + content: ContentTypes, options?: SendOptions ): Promise { const contentType = options?.contentType || ContentTypeText @@ -646,6 +653,39 @@ export default class Client { return proto.EncodedContent.encode(encoded).finish() } + async decodeContent(contentBytes: Uint8Array): Promise<{ + content: ContentTypes + 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), diff --git a/src/Message.ts b/src/Message.ts index 552f6dcd..f98a41e8 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -4,23 +4,14 @@ 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 { - 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' @@ -228,16 +219,17 @@ export class MessageV2 extends MessageBase implements proto.MessageV2 { export type Message = MessageV1 | MessageV2 -export class DecodedMessage { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +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: ContentTypes error?: Error contentBytes: Uint8Array contentFallback?: string @@ -255,7 +247,7 @@ export class DecodedMessage { sent, error, contentFallback, - }: Omit) { + }: Omit, 'toBytes'>) { this.id = id this.messageVersion = messageVersion this.senderAddress = senderAddress @@ -283,10 +275,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 +291,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 +309,16 @@ export class DecodedMessage { }) } - static fromV1Message( + static fromV1Message( message: MessageV1, - content: any, // 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') @@ -347,17 +339,17 @@ export class DecodedMessage { }) } - static fromV2Message( + static fromV2Message( message: MessageV2, - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any + 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({ @@ -376,39 +368,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, + client: Client, version: DecodedMessage['messageVersion'] -): Conversation { +): Conversation { if (version === 'v1') { return new ConversationV1( client, @@ -427,3 +391,10 @@ function conversationReferenceToConversation( } throw new Error(`Unknown conversation version ${version}`) } + +export function decodeContent( + contentBytes: Uint8Array, + client: Client +) { + return client.decodeContent(contentBytes) +} 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 20600956..e8380411 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,11 @@ 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 { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface Conversation { conversationVersion: 'v1' | 'v2' /** * The wallet address connected to the client @@ -80,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. * @@ -104,7 +103,7 @@ export interface Conversation { * } * ``` */ - streamMessages(): Promise> + streamMessages(): Promise, ContentTypes>> /** * Send a message into the conversation * @@ -114,9 +113,9 @@ export interface Conversation { * ``` */ send( - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any + content: Exclude, options?: SendOptions - ): Promise + ): Promise> /** * Return a `PreparedMessage` that has contains the message ID @@ -140,20 +139,22 @@ export interface Conversation { * } * ``` */ - streamEphemeral(): Promise> + 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 @@ -177,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, @@ -190,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 @@ -201,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') } @@ -265,8 +270,8 @@ export class ConversationV1 implements Conversation { */ streamMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise> { - return Stream.create( + ): Promise, ContentTypes>> { + return Stream.create, ContentTypes>( this.client, [this.topic], async (env: messageApi.Envelope) => this.decodeMessage(env), @@ -300,8 +305,8 @@ export class ConversationV1 implements Conversation { streamEphemeral( onConnectionLost?: OnConnectionLostCallback - ): Promise> { - return Stream.create( + ): Promise, ContentTypes>> { + return Stream.create, ContentTypes>( this.client, [this.ephemeralTopic], this.decodeMessage.bind(this), @@ -314,9 +319,9 @@ export class ConversationV1 implements Conversation { * Send a message into the conversation. */ async send( - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any + content: Exclude, options?: SendOptions - ): Promise { + ): Promise> { let topics: string[] let recipient = await this.client.getUserContact(this.peerAddress) if (!recipient) { @@ -364,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] @@ -393,9 +398,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 +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, @@ -459,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), @@ -471,7 +480,7 @@ export class ConversationV2 implements Conversation { messagesPaginated( opts?: ListMessagesPaginatedOptions - ): AsyncGenerator { + ): AsyncGenerator[]> { return this.client.listEnvelopesPaginated( this.topic, this.decodeMessage.bind(this), @@ -485,8 +494,8 @@ export class ConversationV2 implements Conversation { streamEphemeral( onConnectionLost?: OnConnectionLostCallback - ): Promise> { - return Stream.create( + ): Promise, ContentTypes>> { + return Stream.create, ContentTypes>( this.client, [this.ephemeralTopic], this.decodeMessage.bind(this), @@ -500,8 +509,8 @@ export class ConversationV2 implements Conversation { */ streamMessages( onConnectionLost?: OnConnectionLostCallback - ): Promise> { - return Stream.create( + ): Promise, ContentTypes>> { + return Stream.create, ContentTypes>( this.client, [this.topic], this.decodeMessage.bind(this), @@ -514,9 +523,9 @@ export class ConversationV2 implements Conversation { * Send a message into the conversation */ async send( - content: any, // eslint-disable-line @typescript-eslint/no-explicit-any + content: Exclude, options?: SendOptions - ): Promise { + ): Promise> { const payload = await this.client.encodeContent(content, options) const msg = await this.createMessage(payload, options?.timestamp) @@ -582,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] @@ -643,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 ( @@ -673,7 +682,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 +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 65f351cd..1423daf3 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -28,12 +28,13 @@ 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 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +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 +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(), @@ -58,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 @@ -98,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() @@ -121,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) @@ -148,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, @@ -157,7 +165,7 @@ export default class Conversations { })), }) - const out: ConversationV2[] = [] + const out: ConversationV2[] = [] for (const response of responses) { try { out.push(this.saveInviteResponseToConversation(response)) @@ -174,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}}`) } @@ -183,7 +191,7 @@ export default class Conversations { private conversationReferenceToV2( convoRef: conversationReference.ConversationReference - ): ConversationV2 { + ): ConversationV2 { return new ConversationV2( this.client, convoRef.topic, @@ -195,7 +203,7 @@ export default class Conversations { private conversationReferenceToV1( convoRef: conversationReference.ConversationReference - ): ConversationV1 { + ): ConversationV1 { return new ConversationV1( this.client, convoRef.peerAddress, @@ -210,7 +218,7 @@ export default class Conversations { */ async stream( onConnectionLost?: OnConnectionLostCallback - ): Promise> { + ): Promise, ContentTypes>> { const seenPeers: Set = new Set() const introTopic = buildUserIntroTopic(this.client.address) const inviteTopic = buildUserInviteTopic(this.client.address) @@ -249,7 +257,7 @@ export default class Conversations { const topics = [introTopic, inviteTopic] - return Stream.create( + return Stream.create, ContentTypes>( this.client, topics, decodeConversation.bind(this), @@ -267,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) @@ -282,7 +290,9 @@ export default class Conversations { const decodeMessage = async ( env: messageApi.Envelope - ): Promise => { + ): Promise< + Conversation | DecodedMessage | null + > => { const contentTopic = env.contentTopic if (!contentTopic || !env.message) { return null @@ -331,7 +341,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 +353,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 +379,10 @@ export default class Conversations { return undefined } - const str = await Stream.create( + const str = await Stream.create< + DecodedMessage | Conversation | null, + ContentTypes + >( this.client, Array.from(topics.values()), decodeMessage, @@ -391,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 } } @@ -448,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`) @@ -495,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) @@ -520,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, diff --git a/src/index.ts b/src/index.ts index f9594431..196ad7bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ export { Message, DecodedMessage, - decodeContent, MessageV1, MessageV2, + decodeContent, } from './Message' export { Ciphertext, @@ -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 c3773d22..947a8ac0 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' @@ -25,7 +28,7 @@ import LocalStoragePonyfill from '../src/keystore/persistence/LocalStoragePonyfi type TestCase = { name: string - newClient: (opts?: Partial) => Promise + newClient: (opts?: Partial) => Promise> } const mockEthRequest = jest.fn() @@ -198,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, @@ -342,6 +347,40 @@ describe('ClientOptions', () => { }) }) + describe('custom codecs', () => { + it('gives type errors when you use the wrong types', async () => { + 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 { + // 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 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 + 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/Message.test.ts b/test/Message.test.ts index 6ab26a2c..0f95b802 100644 --- a/test/Message.test.ts +++ b/test/Message.test.ts @@ -248,6 +248,12 @@ describe('Message', function () { sentMessageBytes, aliceClient ) + if ( + typeof aliceRestoredMessage.content === 'string' || + !aliceRestoredMessage.content + ) { + 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..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 () => { @@ -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() @@ -425,6 +425,7 @@ describe('conversation', () => { // alice doesn't recognize the type await expect( + // @ts-expect-error aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -432,16 +433,17 @@ describe('conversation', () => { // bob doesn't recognize the type alice.registerCodec(new TestKeyCodec()) + // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) 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' @@ -453,11 +455,12 @@ describe('conversation', () => { // both recognize the type bob.registerCodec(new TestKeyCodec()) + // @ts-expect-error await aliceConvo.send(key, { 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() @@ -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' ) @@ -603,7 +607,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( @@ -663,6 +667,7 @@ describe('conversation', () => { // alice doesn't recognize the type expect( + // @ts-expect-error aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -670,16 +675,17 @@ describe('conversation', () => { // bob doesn't recognize the type alice.registerCodec(new TestKeyCodec()) + // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) 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' @@ -691,11 +697,12 @@ describe('conversation', () => { // both recognize the type bob.registerCodec(new TestKeyCodec()) + // @ts-expect-error await aliceConvo.send(key, { 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() @@ -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' ) 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,