From 8d7b0f0da612ed633282e0e215440685baf44d82 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Fri, 2 Feb 2024 14:18:57 -0700 Subject: [PATCH 1/4] feat: Typescript Updates Updated types to user generics and extract types when sending messages for conversations Updated clients to maintain content codecs Outstanding todos: Replies --- .github/workflows/tsc.yml | 13 ++ example/src/ConversationScreen.tsx | 14 +- example/src/LaunchScreen.tsx | 8 +- example/src/contentTypes/contentTypes.ts | 15 ++ example/src/hooks.tsx | 28 ++-- example/src/tests.ts | 9 +- example/src/types/typeTests.ts | 143 ++++++++++++++++++ src/index.ts | 46 ++++-- src/lib/Client.ts | 47 +++--- src/lib/ContentCodec.ts | 115 +------------- src/lib/Conversation.ts | 37 +++-- src/lib/Conversations.ts | 7 +- src/lib/DecodedMessage.ts | 32 ++-- src/lib/NativeCodecs/ReactionCodec.ts | 2 +- src/lib/NativeCodecs/RemoteAttachmentCodec.ts | 2 +- src/lib/NativeCodecs/ReplyCodec.ts | 2 +- src/lib/NativeCodecs/StaticAttachmentCodec.ts | 2 +- src/lib/NativeCodecs/TextCodec.ts | 2 +- src/lib/types/ContentCodec.ts | 111 ++++++++++++++ src/lib/types/ConversationCodecs.ts | 15 ++ src/lib/types/DefaultContentType.ts | 3 + src/lib/types/ExtractDecodedType.ts | 3 + src/lib/types/SendOptions.ts | 5 + src/lib/types/index.ts | 4 + 24 files changed, 448 insertions(+), 217 deletions(-) create mode 100644 .github/workflows/tsc.yml create mode 100644 example/src/contentTypes/contentTypes.ts create mode 100644 example/src/types/typeTests.ts create mode 100644 src/lib/types/ContentCodec.ts create mode 100644 src/lib/types/ConversationCodecs.ts create mode 100644 src/lib/types/DefaultContentType.ts create mode 100644 src/lib/types/ExtractDecodedType.ts create mode 100644 src/lib/types/SendOptions.ts create mode 100644 src/lib/types/index.ts diff --git a/.github/workflows/tsc.yml b/.github/workflows/tsc.yml new file mode 100644 index 000000000..5d64a66f5 --- /dev/null +++ b/.github/workflows/tsc.yml @@ -0,0 +1,13 @@ +name: Typescript +on: + pull_request: +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/setup-node@v3 + - run: yarn + - run: yarn tsc diff --git a/example/src/ConversationScreen.tsx b/example/src/ConversationScreen.tsx index 93ce3f442..cdda185cb 100644 --- a/example/src/ConversationScreen.tsx +++ b/example/src/ConversationScreen.tsx @@ -32,8 +32,10 @@ import { ReplyContent, useClient, } from 'xmtp-react-native-sdk' +import { ConversationSendPayload } from 'xmtp-react-native-sdk/lib/types' import { NavigationParamList } from './Navigation' +import { SupportedContentTypes } from './contentTypes/contentTypes' import { useConversation, useMessage, @@ -82,7 +84,9 @@ export default function ConversationScreen({ [messages] ) - const sendMessage = async (content: any) => { + const sendMessage = async ( + content: ConversationSendPayload + ) => { setSending(true) console.log('Sending message', content) try { @@ -91,6 +95,7 @@ export default function ConversationScreen({ reply: { reference: replyingTo, content, + contentType: '', }, } : content @@ -103,8 +108,11 @@ export default function ConversationScreen({ setSending(false) } } - const sendRemoteAttachmentMessage = () => - sendMessage({ remoteAttachment }).then(() => setAttachment(null)) + const sendRemoteAttachmentMessage = () => { + if (remoteAttachment) { + sendMessage({ remoteAttachment }).then(() => setAttachment(null)) + } + } const sendTextMessage = () => sendMessage({ text }).then(() => setText('')) const scrollToMessageId = useCallback( (messageId: string) => { diff --git a/example/src/LaunchScreen.tsx b/example/src/LaunchScreen.tsx index 730421797..b9a13beca 100644 --- a/example/src/LaunchScreen.tsx +++ b/example/src/LaunchScreen.tsx @@ -6,17 +6,11 @@ import * as XMTP from 'xmtp-react-native-sdk' import { useXmtp } from 'xmtp-react-native-sdk' import { NavigationParamList } from './Navigation' +import { supportedCodecs } from './contentTypes/contentTypes' import { useSavedKeys } from './hooks' const appVersion = 'XMTP_RN_EX/0.0.1' -const supportedCodecs = [ - new XMTP.ReactionCodec(), - new XMTP.ReplyCodec(), - new XMTP.RemoteAttachmentCodec(), - new XMTP.StaticAttachmentCodec(), -] - /// Prompt the user to run the tests, generate a wallet, or connect a wallet. export default function LaunchScreen({ navigation, diff --git a/example/src/contentTypes/contentTypes.ts b/example/src/contentTypes/contentTypes.ts new file mode 100644 index 000000000..16ddc9c27 --- /dev/null +++ b/example/src/contentTypes/contentTypes.ts @@ -0,0 +1,15 @@ +import { + ReactionCodec, + ReplyCodec, + RemoteAttachmentCodec, + StaticAttachmentCodec, +} from 'xmtp-react-native-sdk' + +export const supportedCodecs = [ + new ReactionCodec(), + new ReplyCodec(), + new RemoteAttachmentCodec(), + new StaticAttachmentCodec(), +] + +export type SupportedContentTypes = typeof supportedCodecs diff --git a/example/src/hooks.tsx b/example/src/hooks.tsx index fec3b547f..c406c4929 100644 --- a/example/src/hooks.tsx +++ b/example/src/hooks.tsx @@ -11,6 +11,7 @@ import { useXmtp, } from 'xmtp-react-native-sdk' +import { SupportedContentTypes } from './contentTypes/contentTypes' import { downloadFile, uploadFile } from './storage' /** @@ -18,12 +19,12 @@ import { downloadFile, uploadFile } from './storage' * * Note: this is better done with a DB, but we're using react-query for now. */ -export function useConversationList(): UseQueryResult< - Conversation[] +export function useConversationList(): UseQueryResult< + Conversation[] > { const { client } = useXmtp() client?.contacts.refreshConsentList() - return useQuery[]>( + return useQuery[]>( ['xmtp', 'conversations', client?.address], () => client!.conversations.list(), { @@ -37,17 +38,17 @@ export function useConversationList(): UseQueryResult< * * Note: this is better done with a DB, but we're using react-query for now. */ -export function useConversation({ +export function useConversation({ topic, }: { topic: string -}): UseQueryResult | undefined> { +}): UseQueryResult | undefined> { const { client } = useXmtp() // TODO: use a DB instead of scanning the cached conversation list return useQuery< - Conversation[], + Conversation[], unknown, - Conversation | undefined + Conversation | undefined >( ['xmtp', 'conversations', client?.address, topic], () => client!.conversations.list(), @@ -67,10 +68,10 @@ export function useMessages({ topic, }: { topic: string -}): UseQueryResult { +}): UseQueryResult[]> { const { client } = useXmtp() const { data: conversation } = useConversation({ topic }) - return useQuery( + return useQuery[]>( ['xmtp', 'messages', client?.address, conversation?.topic], () => conversation!.messages(), { @@ -91,7 +92,7 @@ export function useMessage({ topic: string messageId: string }): { - message: DecodedMessage | undefined + message: DecodedMessage | undefined isSenderMe: boolean performReaction: | undefined @@ -137,8 +138,7 @@ export function useConversationReactions({ topic }: { topic: string }) { const { client } = useXmtp() const { data: messages } = useMessages({ topic }) const reactions = (messages || []).filter( - (message: DecodedMessage) => - message.contentTypeId === 'xmtp.org/reaction:1.0' + (message) => message.contentTypeId === 'xmtp.org/reaction:1.0' ) return useQuery<{ [messageId: string]: { @@ -157,9 +157,9 @@ export function useConversationReactions({ topic }: { topic: string }) { reactions .slice() .reverse() - .forEach((message: DecodedMessage) => { + .forEach((message) => { const { senderAddress } = message - const reaction: ReactionContent = message.content() + const reaction = message.content() as ReactionContent const messageId = reaction!.reference const reactionText = reaction!.content const v = byId[messageId] || ({} as { [reaction: string]: string[] }) diff --git a/example/src/tests.ts b/example/src/tests.ts index f1014ca8e..e4d9bb667 100644 --- a/example/src/tests.ts +++ b/example/src/tests.ts @@ -1284,7 +1284,10 @@ test('register and use custom content types', async () => { const bobConvo = await bob.conversations.newConversation(alice.address) const aliceConvo = await alice.conversations.newConversation(bob.address) - await bobConvo.send(12, { contentType: ContentTypeNumber }) + await bobConvo.send( + { topNumber: { bottomNumber: 12 } }, + { contentType: ContentTypeNumber } + ) const messages = await aliceConvo.messages() assert(messages.length === 1, 'did not get messages') @@ -1293,7 +1296,9 @@ test('register and use custom content types', async () => { const messageContent = message.content() assert( - messageContent === 12, + typeof messageContent === 'object' && + 'topNumber' in messageContent && + messageContent.topNumber.bottomNumber === 1, 'did not get content properly: ' + JSON.stringify(messageContent) ) diff --git a/example/src/types/typeTests.ts b/example/src/types/typeTests.ts new file mode 100644 index 000000000..690cccffa --- /dev/null +++ b/example/src/types/typeTests.ts @@ -0,0 +1,143 @@ +import { + Client, + ContentTypeId, + Conversation, + EncodedContent, + JSContentCodec, + ReactionCodec, + TextCodec, + sendMessage, +} from 'xmtp-react-native-sdk' + +const ContentTypeNumber: ContentTypeId = { + authorityId: 'org', + typeId: 'number', + versionMajor: 1, + versionMinor: 0, +} + +export type NumberRef = { + topNumber: { + bottomNumber: number + } +} + +class NumberCodec implements JSContentCodec { + contentType = ContentTypeNumber + + // a completely absurd way of encoding number values + encode(content: NumberRef): EncodedContent { + return { + type: ContentTypeNumber, + parameters: { + test: 'test', + }, + content: new TextEncoder().encode(JSON.stringify(content)), + } + } + + decode(encodedContent: EncodedContent): NumberRef { + if (encodedContent.parameters.test !== 'test') { + throw new Error(`parameters should parse ${encodedContent.parameters}`) + } + const contentReceived = JSON.parse( + new TextDecoder().decode(encodedContent.content) + ) as NumberRef + return contentReceived + } + + fallback(content: NumberRef): string | undefined { + return 'a billion' + } +} + +export const typeTests = async () => { + const textClient = await Client.createRandom<[TextCodec]>({ env: 'local' }) + const textConvo = (await textClient.conversations.list())[0] + textConvo.send({ text: 'hello' }) + textConvo.send('hello') + // @ts-expect-error + textConvo.send(12312312) + // @ts-expect-error + textConvo.send({ wrong: 'hello' }) + + const textConvo2 = new Conversation(textClient, { + createdAt: 123, + topic: 'sdf', + peerAddress: 'sdf', + version: 'sdf', + }) + textConvo2.send({ text: 'hello' }) + textConvo2.send('hello') + // @ts-expect-error + textConvo2.send(12312312) + // @ts-expect-error + textConvo2.send({ wrong: 'hello' }) + sendMessage<[TextCodec]>('0x1234', 'topic', { text: 'hello' }) + sendMessage<[TextCodec]>('0x1234', 'topic', 'hello') + // @ts-expect-error + sendMessage<[TextCodec]>('0x1234', 'topic', 12314) + + const supportedCodecs = [new ReactionCodec()] + const reactionClient = await Client.createRandom({ + codecs: supportedCodecs, + }) + const reactionConvo = (await reactionClient.conversations.list())[0] + reactionConvo.send({ + reaction: { + action: 'added', + content: '💖', + reference: '123', + schema: 'unicode', + }, + }) + reactionConvo.send({ + // @ts-expect-error + schmeaction: { + action: 'added', + content: '💖', + reference: '123', + schema: 'unicode', + }, + }) + + reactionConvo.send({ + reaction: { + // @ts-expect-error + text: 'added', + }, + }) + reactionConvo.send({ + text: 'text', + }) + + const messages = await reactionConvo.messages() + const content = messages[0].content() + if (typeof content === 'string') { + // + } else { + const reaction = content + const action = reaction.action + // @ts-expect-error + if (action === 12) { + // + } + } + + const customContentClient = await Client.createRandom({ + env: 'local', + codecs: [new NumberCodec()], + }) + const customContentConvo = (await customContentClient.conversations.list())[0] + + customContentConvo.send( + { + topNumber: { + bottomNumber: 12, + }, + }, + { contentType: ContentTypeNumber } + ) + const customContentMessages = await customContentConvo.messages() + customContentMessages[0].content() +} diff --git a/src/index.ts b/src/index.ts index fbe9798b6..453527ead 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,8 @@ import { Conversation } from './lib/Conversation' import { DecodedMessage } from './lib/DecodedMessage' import { Group } from './lib/Group' import type { Query } from './lib/Query' +import { ConversationSendPayload } from './lib/types' +import { DefaultContentTypes } from './lib/types/DefaultContentType' import { getAddress } from './utils/address' export * from './context' @@ -184,7 +186,9 @@ export async function exportConversationTopicData( ) } -export async function importConversationTopicData( +export async function importConversationTopicData< + ContentTypes extends ContentCodec[], +>( client: Client, topicData: string ): Promise> { @@ -238,9 +242,9 @@ export async function decryptAttachment( return JSON.parse(fileJson) } -export async function listConversations( - client: Client -): Promise[]> { +export async function listConversations< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +>(client: Client): Promise[]> { return (await XMTPModule.listConversations(client.address)).map( (json: string) => { return new Conversation(client, JSON.parse(json)) @@ -248,7 +252,9 @@ export async function listConversations( ) } -export async function listMessages( +export async function listMessages< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +>( client: Client, conversationTopic: string, limit?: number | undefined, @@ -258,7 +264,7 @@ export async function listMessages( | 'SORT_DIRECTION_ASCENDING' | 'SORT_DIRECTION_DESCENDING' | undefined -): Promise { +): Promise[]> { const messages = await XMTPModule.loadMessages( client.address, conversationTopic, @@ -273,10 +279,12 @@ export async function listMessages( }) } -export async function listBatchMessages( +export async function listBatchMessages< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +>( client: Client, queries: Query[] -): Promise { +): Promise[]> { const topics = queries.map((item) => { return JSON.stringify({ limit: item.pageSize || 0, @@ -300,7 +308,9 @@ export async function listBatchMessages( } // TODO: support conversation ID -export async function createConversation( +export async function createConversation< + ContentTypes extends ContentCodec[], +>( client: Client, peerAddress: string, context?: ConversationContext @@ -343,10 +353,12 @@ export async function sendWithContentType( } } -export async function sendMessage( +export async function sendMessage< + SendContentTypes extends DefaultContentTypes = DefaultContentTypes, +>( clientAddress: string, conversationTopic: string, - content: any + content: ConversationSendPayload ): Promise { // TODO: consider eager validating of `MessageContent` here // instead of waiting for native code to validate @@ -358,10 +370,12 @@ export async function sendMessage( ) } -export async function prepareMessage( +export async function prepareMessage< + PrepareContentTypes extends DefaultContentTypes = DefaultContentTypes, +>( clientAddress: string, conversationTopic: string, - content: any + content: ConversationSendPayload ): Promise { // TODO: consider eager validating of `MessageContent` here // instead of waiting for native code to validate @@ -451,11 +465,13 @@ export function subscribePushTopics(topics: string[]) { return XMTPModule.subscribePushTopics(topics) } -export async function decodeMessage( +export async function decodeMessage< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +>( clientAddress: string, topic: string, encryptedMessage: string -): Promise { +): Promise> { return JSON.parse( await XMTPModule.decodeMessage(clientAddress, topic, encryptedMessage) ) diff --git a/src/lib/Client.ts b/src/lib/Client.ts index 207ec2646..f6772b3c2 100644 --- a/src/lib/Client.ts +++ b/src/lib/Client.ts @@ -9,12 +9,13 @@ import type { PreparedLocalMessage, } from './ContentCodec' import Conversations from './Conversations' -import { DecodedMessage } from './DecodedMessage' import { TextCodec } from './NativeCodecs/TextCodec' import { Query } from './Query' import { Signer, getSigner } from './Signer' +import { DefaultContentTypes } from './types/DefaultContentType' import { hexToBytes } from './util' import * as XMTPModule from '../index' +import { DecodedMessage } from '../index' declare const Buffer @@ -26,7 +27,9 @@ export type ExtractDecodedType = C extends XMTPModule.ContentCodec ? T : never -export class Client { +export class Client< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +> { address: string conversations: Conversations contacts: Contacts @@ -44,15 +47,11 @@ export class Client { * See {@link https://xmtp.org/docs/build/authentication#create-a-client | XMTP Docs} for more information. */ static async create< - ContentCodecs extends XMTPModule.ContentCodec[] = [], + ContentCodecs extends DefaultContentTypes = DefaultContentTypes, >( wallet: Signer | WalletClient | null, opts?: Partial & { codecs?: ContentCodecs } - ): Promise< - Client< - ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined - > - > { + ): Promise> { const options = defaultOptions(opts) const { enableSubscription, createSubscription } = this.setupSubscriptions(options) @@ -60,11 +59,7 @@ export class Client { if (!signer) { throw new Error('Signer is not configured') } - return new Promise< - Client< - ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined - > - >((resolve, reject) => { + return new Promise>((resolve, reject) => { ;(async () => { this.signSubscription = XMTPModule.emitter.addListener( 'sign', @@ -138,15 +133,9 @@ export class Client { * @param {Partial} opts - Optional configuration options for the Client. * @returns {Promise} A Promise that resolves to a new Client instance with a random address. */ - static async createRandom< - ContentCodecs extends XMTPModule.ContentCodec[] = [], - >( - opts?: Partial & { codecs?: ContentCodecs } - ): Promise< - Client< - ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined - > - > { + static async createRandom( + opts?: Partial & { codecs?: ContentTypes } + ): Promise> { const options = defaultOptions(opts) const { enableSubscription, createSubscription } = this.setupSubscriptions(options) @@ -174,15 +163,11 @@ export class Client { * @returns {Promise} A Promise that resolves to a new Client instance based on the provided key bundle. */ static async createFromKeyBundle< - ContentCodecs extends XMTPModule.ContentCodec[] = [], + ContentCodecs extends DefaultContentTypes = [], >( keyBundle: string, opts?: Partial & { codecs?: ContentCodecs } - ): Promise< - Client< - ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined - > - > { + ): Promise> { const options = defaultOptions(opts) const address = await XMTPModule.createFromKeyBundle( keyBundle, @@ -328,9 +313,11 @@ export class Client { * @returns {Promise} A Promise that resolves to a list of batch messages. * @throws {Error} The error is logged, and the method gracefully returns an empty array. */ - async listBatchMessages(queries: Query[]): Promise { + async listBatchMessages( + queries: Query[] + ): Promise[]> { try { - return await XMTPModule.listBatchMessages(this, queries) + return await XMTPModule.listBatchMessages(this, queries) } catch (e) { console.info('ERROR in listBatchMessages', e) return [] diff --git a/src/lib/ContentCodec.ts b/src/lib/ContentCodec.ts index b690dd7d8..8370098e2 100644 --- a/src/lib/ContentCodec.ts +++ b/src/lib/ContentCodec.ts @@ -1,114 +1 @@ -import { content } from '@xmtp/proto' -import { ReadReceiptCodec } from './NativeCodecs/ReadReceiptCodec' -import { TextCodec } from './NativeCodecs/TextCodec' -import { ReplyCodec } from './NativeCodecs/ReplyCodec' - -export type EncodedContent = content.EncodedContent -export type ContentTypeId = content.ContentTypeId - -export type UnknownContent = { - contentTypeId: string -} - -export type ReadReceiptContent = object - -export type ReplyContent = { - reference: string - content: any - contentType: string -} - -export type ReactionContent = { - reference: string - action: 'added' | 'removed' | 'unknown' - schema: 'unicode' | 'shortcode' | 'custom' | 'unknown' - content: string -} - -export type StaticAttachmentContent = { - filename: string - mimeType: string - data: string -} - -export type DecryptedLocalAttachment = { - fileUri: string - mimeType?: string - filename?: string -} - -export type RemoteAttachmentMetadata = { - filename?: string - secret: string - salt: string - nonce: string - contentDigest: string - contentLength?: string -} - -export type EncryptedLocalAttachment = { - encryptedLocalFileUri: string - metadata: RemoteAttachmentMetadata -} - -export type RemoteAttachmentContent = RemoteAttachmentMetadata & { - scheme: 'https://' - url: string -} - -// This contains a message that has been prepared for sending. -// It contains the message ID and the URI of a local file -// containing the payload that needs to be published. -// See Conversation.sendPreparedMessage() and Client.sendPreparedMessage() -// -// For native integrations (e.g. if you have native code for a robust -// pending-message queue in a background task) you can load the referenced -// `preparedFileUri` as a serialized `PreparedMessage` with the native SDKs. -// The contained `envelopes` can then be directly `.publish()`ed with the native `Client`. -// e.g. on iOS: -// let preparedFileUrl = URL(string: preparedFileUri) -// let preparedData = try Data(contentsOf: preparedFileUrl) -// let prepared = try PreparedMessage.fromSerializedData(preparedData) -// try await client.publish(envelopes: prepared.envelopes) -// e.g. on Android: -// val preparedFileUri = Uri.parse(preparedFileUri) -// val preparedData = contentResolver.openInputStream(preparedFileUrl)!! -// .use { it.buffered().readBytes() } -// val prepared = PreparedMessage.fromSerializedData(preparedData) -// client.publish(envelopes = prepared.envelopes) -// -// You can also stuff the `preparedData` elsewhere (e.g. in a database) if that -// is more convenient for your use case. -export type PreparedLocalMessage = { - messageId: string - preparedFileUri: `file://${string}` - preparedAt: number // timestamp in milliseconds -} - -export type NativeMessageContent = { - text?: string - unknown?: UnknownContent - reply?: ReplyContent - reaction?: ReactionContent - attachment?: StaticAttachmentContent - remoteAttachment?: RemoteAttachmentContent - readReceipt?: ReadReceiptContent - encoded?: string -} - -export interface JSContentCodec { - contentType: ContentTypeId - encode(content: T): EncodedContent - decode(encodedContent: EncodedContent): T - fallback(content: T): string | undefined -} - -export interface NativeContentCodec { - contentKey: string - contentType: ContentTypeId - encode(content: T): NativeMessageContent - decode(nativeContent: NativeMessageContent): T - fallback(content: T): string | undefined -} - -export type ContentCodec = JSContentCodec | NativeContentCodec +export * from './types/ContentCodec' diff --git a/src/lib/Conversation.ts b/src/lib/Conversation.ts index 306b2305d..436ad8185 100644 --- a/src/lib/Conversation.ts +++ b/src/lib/Conversation.ts @@ -1,12 +1,18 @@ -import { DecodedMessage } from './DecodedMessage' +import { ContentTypeId } from './types/ContentCodec' +import { ConversationSendPayload } from './types/ConversationCodecs' +import { DefaultContentTypes } from './types/DefaultContentType' import * as XMTP from '../index' -import { ConversationContext, PreparedLocalMessage } from '../index' +import { + ConversationContext, + DecodedMessage, + PreparedLocalMessage, +} from '../index' export type SendOptions = { - contentType?: XMTP.ContentTypeId + contentType?: ContentTypeId } -export class Conversation { +export class Conversation { client: XMTP.Client createdAt: number context?: ConversationContext @@ -74,7 +80,7 @@ export class Conversation { | undefined ): Promise[]> { try { - const messages = await XMTP.listMessages( + const messages = await XMTP.listMessages( this.client, this.topic, limit, @@ -120,7 +126,10 @@ export class Conversation { * * @todo Support specifying a conversation ID in future implementations. */ - async send(content: any, opts?: SendOptions): Promise { + async send( + content: ConversationSendPayload, + opts?: SendOptions + ): Promise { if (opts && opts.contentType) { return await this._sendWithJSCodec(content, opts.contentType) } @@ -154,7 +163,7 @@ export class Conversation { this.client.address, this.topic, content, - codec, + codec ) } @@ -173,8 +182,10 @@ export class Conversation { * @returns {Promise} A Promise that resolves to a `PreparedLocalMessage` object. * @throws {Error} Throws an error if there is an issue with preparing the message. */ - async prepareMessage( - content: any, + async prepareMessage< + PrepareContentTypes extends DefaultContentTypes = ContentTypes, + >( + content: ConversationSendPayload, opts?: SendOptions ): Promise { if (opts && opts.contentType) { @@ -220,7 +231,9 @@ export class Conversation { * @returns {Promise} A Promise that resolves to a `DecodedMessage` object. * @throws {Error} Throws an error if there is an issue with decoding the message. */ - async decodeMessage(encryptedMessage: string): Promise { + async decodeMessage( + encryptedMessage: string + ): Promise> { try { return await XMTP.decodeMessage( this.client.address, @@ -256,7 +269,7 @@ export class Conversation { * @returns {Function} A function that, when called, unsubscribes from the message stream and ends real-time updates. */ streamMessages( - callback: (message: DecodedMessage) => Promise + callback: (message: DecodedMessage) => Promise ): () => void { XMTP.subscribeToMessages(this.client.address, this.topic) const hasSeen = {} @@ -267,7 +280,7 @@ export class Conversation { message, }: { clientAddress: string - message: DecodedMessage + message: DecodedMessage }) => { if (clientAddress !== this.client.address) { return diff --git a/src/lib/Conversations.ts b/src/lib/Conversations.ts index 9d14dcb02..57d9fae6a 100644 --- a/src/lib/Conversations.ts +++ b/src/lib/Conversations.ts @@ -4,9 +4,12 @@ import { DecodedMessage } from './DecodedMessage' import { Group } from './Group' import { ConversationContext } from '../XMTP.types' import * as XMTPModule from '../index' +import { ContentCodec } from '../index' import { getAddress } from '../utils/address' -export default class Conversations { +export default class Conversations< + ContentTypes extends ContentCodec[] = [], +> { client: Client private known = {} as { [topic: string]: boolean } @@ -165,7 +168,7 @@ export default class Conversations { * @returns {Promise} A Promise that resolves when the stream is set up. */ async streamAllMessages( - callback: (message: DecodedMessage) => Promise + callback: (message: DecodedMessage) => Promise ): Promise { XMTPModule.subscribeToAllMessages(this.client.address) XMTPModule.emitter.addListener( diff --git a/src/lib/DecodedMessage.ts b/src/lib/DecodedMessage.ts index 103e3116c..80a46da38 100644 --- a/src/lib/DecodedMessage.ts +++ b/src/lib/DecodedMessage.ts @@ -1,15 +1,17 @@ -import { Client } from './Client' +import { Buffer } from 'buffer' + +import { Client, ExtractDecodedType } from './Client' import { - ContentCodec, JSContentCodec, NativeContentCodec, NativeMessageContent, } from './ContentCodec' -import { ReplyCodec } from './NativeCodecs/ReplyCodec' import { TextCodec } from './NativeCodecs/TextCodec' -import { Buffer } from 'buffer' +import { DefaultContentTypes } from './types/DefaultContentType' -export class DecodedMessage { +export class DecodedMessage< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +> { client: Client id: string topic: string @@ -19,10 +21,10 @@ export class DecodedMessage { nativeContent: NativeMessageContent fallback: string | undefined - static from( + static from( json: string, client: Client - ): DecodedMessage { + ): DecodedMessage { const decoded = JSON.parse(json) return new DecodedMessage( client, @@ -36,7 +38,9 @@ export class DecodedMessage { ) } - static fromObject( + static fromObject< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, + >( object: { id: string topic: string @@ -80,13 +84,13 @@ export class DecodedMessage { this.fallback = fallback } - content(): ContentTypes { + content(): ExtractDecodedType<[...ContentTypes, TextCodec][number] | string> { const encodedJSON = this.nativeContent.encoded if (encodedJSON) { const encoded = JSON.parse(encodedJSON) const codec = this.client.codecRegistry[ this.contentTypeId - ] as JSContentCodec + ] as JSContentCodec> if (!codec) { throw new Error( `no content type found ${JSON.stringify(this.contentTypeId)}` @@ -102,9 +106,11 @@ export class DecodedMessage { ('contentKey' in codec && this.nativeContent[codec.contentKey]) || this.nativeContent.hasOwnProperty('text') ) { - return (codec as NativeContentCodec).decode( - this.nativeContent - ) + return ( + codec as NativeContentCodec< + ExtractDecodedType + > + ).decode(this.nativeContent) } } diff --git a/src/lib/NativeCodecs/ReactionCodec.ts b/src/lib/NativeCodecs/ReactionCodec.ts index 9eabf8e6c..58a77e406 100644 --- a/src/lib/NativeCodecs/ReactionCodec.ts +++ b/src/lib/NativeCodecs/ReactionCodec.ts @@ -6,7 +6,7 @@ import { } from '../ContentCodec' export class ReactionCodec implements NativeContentCodec { - contentKey: string = 'reaction' + contentKey: 'reaction' = 'reaction' contentType: ContentTypeId = { authorityId: 'xmtp.org', diff --git a/src/lib/NativeCodecs/RemoteAttachmentCodec.ts b/src/lib/NativeCodecs/RemoteAttachmentCodec.ts index 026a2767c..821522e18 100644 --- a/src/lib/NativeCodecs/RemoteAttachmentCodec.ts +++ b/src/lib/NativeCodecs/RemoteAttachmentCodec.ts @@ -8,7 +8,7 @@ import { export class RemoteAttachmentCodec implements NativeContentCodec { - contentKey: string = 'remoteAttachment' + contentKey: 'remoteAttachment' = 'remoteAttachment' contentType: ContentTypeId = { authorityId: 'xmtp.org', diff --git a/src/lib/NativeCodecs/ReplyCodec.ts b/src/lib/NativeCodecs/ReplyCodec.ts index 10cecb4ce..03ca03a20 100644 --- a/src/lib/NativeCodecs/ReplyCodec.ts +++ b/src/lib/NativeCodecs/ReplyCodec.ts @@ -11,7 +11,7 @@ export type ReplyContent = { } export class ReplyCodec implements NativeContentCodec { - contentKey: string = 'reply' + contentKey: 'reply' = 'reply' contentType: ContentTypeId = { authorityId: 'xmtp.org', diff --git a/src/lib/NativeCodecs/StaticAttachmentCodec.ts b/src/lib/NativeCodecs/StaticAttachmentCodec.ts index c0c0acd3b..7d83f5678 100644 --- a/src/lib/NativeCodecs/StaticAttachmentCodec.ts +++ b/src/lib/NativeCodecs/StaticAttachmentCodec.ts @@ -8,7 +8,7 @@ import { export class StaticAttachmentCodec implements NativeContentCodec { - contentKey: string = 'attachment' + contentKey: 'attachment' = 'attachment' contentType: ContentTypeId = { authorityId: 'xmtp.org', diff --git a/src/lib/NativeCodecs/TextCodec.ts b/src/lib/NativeCodecs/TextCodec.ts index f61101460..bf7d6dc1c 100644 --- a/src/lib/NativeCodecs/TextCodec.ts +++ b/src/lib/NativeCodecs/TextCodec.ts @@ -5,7 +5,7 @@ import { } from '../ContentCodec' export class TextCodec implements NativeContentCodec { - contentKey: string = 'text' + contentKey: 'text' = 'text' contentType: ContentTypeId = { authorityId: 'xmtp.org', diff --git a/src/lib/types/ContentCodec.ts b/src/lib/types/ContentCodec.ts new file mode 100644 index 000000000..c108aa5fc --- /dev/null +++ b/src/lib/types/ContentCodec.ts @@ -0,0 +1,111 @@ +import { content } from '@xmtp/proto' + +export type EncodedContent = content.EncodedContent +export type ContentTypeId = content.ContentTypeId + +export type UnknownContent = { + contentTypeId: string +} + +export type ReadReceiptContent = object + +export type ReplyContent = { + reference: string + content: any + contentType: string +} + +export type ReactionContent = { + reference: string + action: 'added' | 'removed' | 'unknown' + schema: 'unicode' | 'shortcode' | 'custom' | 'unknown' + content: string +} + +export type StaticAttachmentContent = { + filename: string + mimeType: string + data: string +} + +export type DecryptedLocalAttachment = { + fileUri: string + mimeType?: string + filename?: string +} + +export type RemoteAttachmentMetadata = { + filename?: string + secret: string + salt: string + nonce: string + contentDigest: string + contentLength?: string +} + +export type EncryptedLocalAttachment = { + encryptedLocalFileUri: string + metadata: RemoteAttachmentMetadata +} + +export type RemoteAttachmentContent = RemoteAttachmentMetadata & { + scheme: 'https://' + url: string +} + +// This contains a message that has been prepared for sending. +// It contains the message ID and the URI of a local file +// containing the payload that needs to be published. +// See Conversation.sendPreparedMessage() and Client.sendPreparedMessage() +// +// For native integrations (e.g. if you have native code for a robust +// pending-message queue in a background task) you can load the referenced +// `preparedFileUri` as a serialized `PreparedMessage` with the native SDKs. +// The contained `envelopes` can then be directly `.publish()`ed with the native `Client`. +// e.g. on iOS: +// let preparedFileUrl = URL(string: preparedFileUri) +// let preparedData = try Data(contentsOf: preparedFileUrl) +// let prepared = try PreparedMessage.fromSerializedData(preparedData) +// try await client.publish(envelopes: prepared.envelopes) +// e.g. on Android: +// val preparedFileUri = Uri.parse(preparedFileUri) +// val preparedData = contentResolver.openInputStream(preparedFileUrl)!! +// .use { it.buffered().readBytes() } +// val prepared = PreparedMessage.fromSerializedData(preparedData) +// client.publish(envelopes = prepared.envelopes) +// +// You can also stuff the `preparedData` elsewhere (e.g. in a database) if that +// is more convenient for your use case. +export type PreparedLocalMessage = { + messageId: string + preparedFileUri: `file://${string}` + preparedAt: number // timestamp in milliseconds +} + +export type NativeMessageContent = { + text?: string + unknown?: UnknownContent + reply?: ReplyContent + reaction?: ReactionContent + attachment?: StaticAttachmentContent + remoteAttachment?: RemoteAttachmentContent + readReceipt?: ReadReceiptContent + encoded?: string +} + +export interface JSContentCodec { + contentType: ContentTypeId + encode(content: T): EncodedContent + decode(encodedContent: EncodedContent): T + fallback(content: T): string | undefined +} + +export interface NativeContentCodec { + contentKey: string + contentType: ContentTypeId + encode(content: T): NativeMessageContent + decode(nativeContent: NativeMessageContent): T + fallback(content: T): string | undefined +} + +export type ContentCodec = JSContentCodec | NativeContentCodec diff --git a/src/lib/types/ConversationCodecs.ts b/src/lib/types/ConversationCodecs.ts new file mode 100644 index 000000000..8e8613cfe --- /dev/null +++ b/src/lib/types/ConversationCodecs.ts @@ -0,0 +1,15 @@ +import { ContentCodec } from './ContentCodec' +import { ExtractDecodedType } from './ExtractDecodedType' +import { TextCodec } from '../NativeCodecs/TextCodec' + +export type WithTextContentCode = { text: string } +export type ContentCodecMap> = + ContentTypes extends infer T + ? T extends { contentKey: infer K } + ? { [key in K extends string ? K : never]: ExtractDecodedType } + : ExtractDecodedType + : never + +export type ConversationSendPayload[]> = + | ContentCodecMap + | string diff --git a/src/lib/types/DefaultContentType.ts b/src/lib/types/DefaultContentType.ts new file mode 100644 index 000000000..19fb00ba2 --- /dev/null +++ b/src/lib/types/DefaultContentType.ts @@ -0,0 +1,3 @@ +import { ContentCodec } from './ContentCodec' + +export type DefaultContentTypes = ContentCodec[] diff --git a/src/lib/types/ExtractDecodedType.ts b/src/lib/types/ExtractDecodedType.ts new file mode 100644 index 000000000..b90ea85c5 --- /dev/null +++ b/src/lib/types/ExtractDecodedType.ts @@ -0,0 +1,3 @@ +import { ContentCodec } from './ContentCodec' + +export type ExtractDecodedType = C extends ContentCodec ? T : never diff --git a/src/lib/types/SendOptions.ts b/src/lib/types/SendOptions.ts new file mode 100644 index 000000000..3944f2dfc --- /dev/null +++ b/src/lib/types/SendOptions.ts @@ -0,0 +1,5 @@ +import { ContentTypeId } from './ContentCodec' + +export type SendOptions = { + contentType?: ContentTypeId +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 000000000..1248d4ea3 --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,4 @@ +export * from './ContentCodec' +export * from './SendOptions' +export * from './ExtractDecodedType' +export * from './ConversationCodecs' From f9f60d415dc8eea4964d70a2de9184573c55d320 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Mon, 5 Feb 2024 13:33:57 -0700 Subject: [PATCH 2/4] Typescript Updates Added better handling for Replies --- example/src/ConversationScreen.tsx | 19 ++++++++++--------- src/hooks/useClient.ts | 9 +++++++-- src/lib/DecodedMessage.ts | 6 ++++-- src/lib/NativeCodecs/ReplyCodec.ts | 8 ++++++-- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/example/src/ConversationScreen.tsx b/example/src/ConversationScreen.tsx index cdda185cb..b7b45e626 100644 --- a/example/src/ConversationScreen.tsx +++ b/example/src/ConversationScreen.tsx @@ -91,13 +91,12 @@ export default function ConversationScreen({ console.log('Sending message', content) try { content = replyingTo - ? { + ? ({ reply: { reference: replyingTo, content, - contentType: '', }, - } + } as ConversationSendPayload) : content await conversation!.send(content) await refreshMessages() @@ -812,6 +811,7 @@ function ReplyMessageHeader({ /> ) } + const content = message.content() return ( - {message.content().text ? ( + {typeof content !== 'string' && 'text' in content && content.text ? ( - {message.content().text} + {content.text as string} ) : ( @@ -906,9 +906,10 @@ function MessageItem({ return null } let content = message.content() - const replyingTo = content.reply?.reference - if (content.reply) { - content = content.reply.content + const replyingTo = (content as ReplyContent)?.reference + if (replyingTo) { + const replyContent = (content as ReplyContent).content + content = replyContent as typeof content } showSender = !!(replyingTo || showSender) return ( @@ -1066,7 +1067,7 @@ function MessageContents({ contentTypeId: string content: any }) { - const { client } = useClient() + const { client } = useClient() if (contentTypeId === 'xmtp.org/text:1.0') { const text: string = content diff --git a/src/hooks/useClient.ts b/src/hooks/useClient.ts index d1f86577d..df6390773 100644 --- a/src/hooks/useClient.ts +++ b/src/hooks/useClient.ts @@ -3,13 +3,18 @@ import { useCallback, useRef, useState } from 'react' import { useXmtp } from './useXmtp' import { Client, ClientOptions } from '../lib/Client' import { Signer } from '../lib/Signer' +import { DefaultContentTypes } from '../lib/types/DefaultContentType' interface InitializeClientOptions { signer: Signer | null options?: ClientOptions } -export const useClient = (onError?: (e: Error) => void) => { +export const useClient = < + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +>( + onError?: (e: Error) => void +) => { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) // client is initializing @@ -71,7 +76,7 @@ export const useClient = (onError?: (e: Error) => void) => { }, [client, setClient]) return { - client, + client: client as Client | null, error, initialize, disconnect, diff --git a/src/lib/DecodedMessage.ts b/src/lib/DecodedMessage.ts index 80a46da38..43acd0c06 100644 --- a/src/lib/DecodedMessage.ts +++ b/src/lib/DecodedMessage.ts @@ -90,7 +90,9 @@ export class DecodedMessage< const encoded = JSON.parse(encodedJSON) const codec = this.client.codecRegistry[ this.contentTypeId - ] as JSContentCodec> + ] as JSContentCodec< + ExtractDecodedType<[...ContentTypes, TextCodec][number]> + > if (!codec) { throw new Error( `no content type found ${JSON.stringify(this.contentTypeId)}` @@ -108,7 +110,7 @@ export class DecodedMessage< ) { return ( codec as NativeContentCodec< - ExtractDecodedType + ExtractDecodedType<[...ContentTypes, TextCodec][number]> > ).decode(this.nativeContent) } diff --git a/src/lib/NativeCodecs/ReplyCodec.ts b/src/lib/NativeCodecs/ReplyCodec.ts index 03ca03a20..060c4244c 100644 --- a/src/lib/NativeCodecs/ReplyCodec.ts +++ b/src/lib/NativeCodecs/ReplyCodec.ts @@ -1,12 +1,16 @@ +import { TextCodec } from './TextCodec' import { ContentTypeId, NativeContentCodec, NativeMessageContent, } from '../ContentCodec' +import { DefaultContentTypes } from '../types/DefaultContentType' -export type ReplyContent = { +export type ReplyContent< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +> = { reference: string - content: any + content: [...ContentTypes, TextCodec][number] | string contentType: string } From fa8a2080ae2a3bf743990d75e238108f145f0ebf Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Thu, 8 Feb 2024 15:25:23 -0700 Subject: [PATCH 3/4] Typescript generics for groups --- src/index.ts | 17 +++++++++-------- src/lib/Group.ts | 5 ++++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 453527ead..1a092e44c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,7 +86,9 @@ export async function createFromKeyBundle( ) } -export async function createGroup( +export async function createGroup< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +>( client: Client, peerAddresses: string[] ): Promise> { @@ -96,18 +98,17 @@ export async function createGroup( ) } -export async function listGroups( - client: Client -): Promise[]> { +export async function listGroups< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +>(client: Client): Promise[]> { return (await XMTPModule.listGroups(client.address)).map((json: string) => { return new Group(client, JSON.parse(json)) }) } -export async function listMemberAddresses( - client: Client, - id: string -): Promise { +export async function listMemberAddresses< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +>(client: Client, id: string): Promise { return XMTPModule.listMemberAddresses(client.address, id) } diff --git a/src/lib/Group.ts b/src/lib/Group.ts index 006cc546f..f9b658936 100644 --- a/src/lib/Group.ts +++ b/src/lib/Group.ts @@ -1,8 +1,11 @@ import { SendOptions } from './Conversation' import { DecodedMessage } from './DecodedMessage' +import { DefaultContentTypes } from './types/DefaultContentType' import * as XMTP from '../index' -export class Group { +export class Group< + ContentTypes extends DefaultContentTypes = DefaultContentTypes, +> { client: XMTP.Client id: string createdAt: number From 6329487b70107ef36f8abb254c2435aa6276dbc4 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Mon, 12 Feb 2024 17:36:37 -0700 Subject: [PATCH 4/4] feat: Group send payload types Added types on group send method and handled typed for decoded messages in group --- example/src/types/typeTests.ts | 16 ++++++++++++++++ src/lib/Group.ts | 10 +++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/example/src/types/typeTests.ts b/example/src/types/typeTests.ts index 690cccffa..6a41e75a0 100644 --- a/example/src/types/typeTests.ts +++ b/example/src/types/typeTests.ts @@ -138,6 +138,22 @@ export const typeTests = async () => { }, { contentType: ContentTypeNumber } ) + + const customContentGroup = (await customContentClient.conversations.list())[0] + + await customContentGroup.send( + { + topNumber: { + bottomNumber: 12, + }, + }, + { contentType: ContentTypeNumber } + ) const customContentMessages = await customContentConvo.messages() customContentMessages[0].content() + + await customContentGroup.send({ + // @ts-expect-error + test: 'test', + }) } diff --git a/src/lib/Group.ts b/src/lib/Group.ts index f9b658936..842cdb2f2 100644 --- a/src/lib/Group.ts +++ b/src/lib/Group.ts @@ -1,5 +1,6 @@ import { SendOptions } from './Conversation' import { DecodedMessage } from './DecodedMessage' +import { ConversationSendPayload } from './types/ConversationCodecs' import { DefaultContentTypes } from './types/DefaultContentType' import * as XMTP from '../index' @@ -42,7 +43,10 @@ export class Group< * * @todo Support specifying a conversation ID in future implementations. */ - async send(content: any, opts?: SendOptions): Promise { + async send( + content: ConversationSendPayload, + opts?: SendOptions + ): Promise { // TODO: Enable other content types // if (opts && opts.contentType) { // return await this._sendWithJSCodec(content, opts.contentType) @@ -83,7 +87,7 @@ export class Group< * @returns {Function} A function that, when called, unsubscribes from the message stream and ends real-time updates. */ streamGroupMessages( - callback: (message: DecodedMessage) => Promise + callback: (message: DecodedMessage) => Promise ): () => void { XMTP.subscribeToGroupMessages(this.client.address, this.id) const hasSeen = {} @@ -94,7 +98,7 @@ export class Group< message, }: { clientAddress: string - message: DecodedMessage + message: DecodedMessage }) => { if (clientAddress !== this.client.address) { return