From 7ac8862d6ae5da7d53bd26a163064dbe8f6ea911 Mon Sep 17 00:00:00 2001 From: Daniel McCartney Date: Mon, 7 Aug 2023 20:20:30 -0400 Subject: [PATCH] feat: implement codecs for attachment, reaction, and reply --- lib/src/client.dart | 17 +++- lib/src/content/attachment_codec.dart | 47 +++++++++ lib/src/content/codec.dart | 18 ++++ lib/src/content/composite_codec.dart | 15 ++- lib/src/content/reaction_codec.dart | 69 +++++++++++++ lib/src/content/reply_codec.dart | 50 ++++++++++ test/client_test.dart | 133 +++++++++++++++++++++++++ test/content/attachment_test.dart | 18 ++++ test/content/composite_codec_test.dart | 6 +- test/content/reaction_codec_test.dart | 19 ++++ test/content/reply_codec_test.dart | 24 +++++ 11 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 lib/src/content/attachment_codec.dart create mode 100644 lib/src/content/reaction_codec.dart create mode 100644 lib/src/content/reply_codec.dart create mode 100644 test/content/attachment_test.dart create mode 100644 test/content/reaction_codec_test.dart create mode 100644 test/content/reply_codec_test.dart diff --git a/lib/src/client.dart b/lib/src/client.dart index dadde54..9bfa043 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -5,9 +5,12 @@ import 'auth.dart'; import 'common/api.dart'; import 'common/signature.dart'; import 'contact.dart'; +import 'content/attachment_codec.dart'; import 'content/codec.dart'; import 'content/codec_registry.dart'; import 'content/composite_codec.dart'; +import 'content/reaction_codec.dart'; +import 'content/reply_codec.dart'; import 'content/text_codec.dart'; import 'content/decoded.dart'; import 'conversation/conversation.dart'; @@ -117,9 +120,17 @@ class Client implements Codec { var auth = AuthManager(address, api); var contacts = ContactManager(api); var codecs = CodecRegistry(); - codecs.registerCodec(TextCodec()); - codecs.registerCodec(CompositeCodec(codecs)); - for (var codec in customCodecs) { + var commonCodecs = [ + TextCodec(), + CompositeCodec(), + ReplyCodec(), + ReactionCodec(), + AttachmentCodec(), + ]; + for (var codec in commonCodecs..addAll(customCodecs)) { + if (codec is NestedContentCodec) { + codec.setRegistry(codecs); + } codecs.registerCodec(codec); } var v1 = ConversationManagerV1(address, api, auth, codecs, contacts); diff --git a/lib/src/content/attachment_codec.dart b/lib/src/content/attachment_codec.dart new file mode 100644 index 0000000..6f1b574 --- /dev/null +++ b/lib/src/content/attachment_codec.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:xmtp_proto/xmtp_proto.dart' as xmtp; + +import 'codec.dart'; + +final contentTypeAttachment = xmtp.ContentTypeId( + authorityId: "xmtp.org", + typeId: "attachment", + versionMajor: 1, + versionMinor: 0, +); + +/// This is a file attached as the message content. +/// +/// Note: this is limited to small files that can fit in the message payload. +/// For larger files, use [RemoteAttachment]. +class Attachment { + final String filename; + final String mimeType; + final List data; + + Attachment(this.filename, this.mimeType, this.data); +} + +/// This is a [Codec] that encodes a file attached as the message content. +class AttachmentCodec extends Codec { + @override + xmtp.ContentTypeId get contentType => contentTypeAttachment; + + @override + Future decode(xmtp.EncodedContent encoded) async => + Attachment( + encoded.parameters["filename"] ?? "", + encoded.parameters["mimeType"] ?? "", + encoded.content, + ); + + @override + Future encode(Attachment decoded) async => xmtp.EncodedContent( + type: contentTypeAttachment, + parameters: { + "filename": decoded.filename, + "mimeType": decoded.mimeType, + }, + content: decoded.data, + ); +} diff --git a/lib/src/content/codec.dart b/lib/src/content/codec.dart index 98fa736..d516907 100644 --- a/lib/src/content/codec.dart +++ b/lib/src/content/codec.dart @@ -1,5 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:xmtp_proto/xmtp_proto.dart' as xmtp; +import 'decoded.dart'; + /// This defines the interface for a content codec of a particular type [T]. /// It is responsible for knowing how to [encode] the content [T]. /// And it is responsible for knowing how to [decode] the [EncodedContent]. @@ -15,3 +18,18 @@ abstract class Codec { /// This is called to encode the content Future encode(T decoded); } + +/// This is a [Codec] that can handle nested generic content. +/// +/// These codecs need the full [CodecRegistry] to decode and encode some nested +/// content as part of implementing their own [encode] and [decode]. +/// +/// See e.g. [CompositeCodec] and [ReplyCodec]. +abstract class NestedContentCodec implements Codec { + @protected + late Codec registry; + + void setRegistry(Codec registry_) { + registry = registry_; + } +} diff --git a/lib/src/content/composite_codec.dart b/lib/src/content/composite_codec.dart index e945784..63575bf 100644 --- a/lib/src/content/composite_codec.dart +++ b/lib/src/content/composite_codec.dart @@ -13,16 +13,13 @@ final contentTypeComposite = xmtp.ContentTypeId( /// This is a [Codec] that can handle a composite of other content types. /// -/// It is initialized with a [CodecRegistry] that it uses to encode and decode -/// the parts of the composite. +/// It is a [NestedContentCodec] so it has a [CodecRegistry] it uses to encode +/// and decode the parts of the composite. /// /// Both [encode] and [decode] are implemented by recursing through the /// composite and serializing/deserializing to the [xmtp.Composite] as /// the [content] bytes in a [xmtp.EncodedContent] of type [contentTypeComposite]. -class CompositeCodec extends Codec { - final Codec _registry; - - CompositeCodec(this._registry); +class CompositeCodec extends NestedContentCodec { @override xmtp.ContentTypeId get contentType => contentTypeComposite; @@ -47,7 +44,7 @@ class CompositeCodec extends Codec { var results = []; for (var part in composite.parts) { if (part.hasPart()) { - var decoded = await _registry.decode(part.part); + var decoded = await registry.decode(part.part); results.add(DecodedComposite.ofContent(decoded)); } else { var decoded = await _decode(part.composite); @@ -65,13 +62,13 @@ class CompositeCodec extends Codec { /// This recursively encodes the parts of the composite. Future _encode(DecodedComposite decoded) async { if (decoded.hasContent) { - var encoded = await _registry.encode(decoded.content!); + var encoded = await registry.encode(decoded.content!); return xmtp.Composite()..parts.add(xmtp.Composite_Part(part: encoded)); } var result = xmtp.Composite(); for (var part in decoded.parts) { if (part.hasContent) { - var encoded = await _registry.encode(part.content!); + var encoded = await registry.encode(part.content!); result.parts.add(xmtp.Composite_Part(part: encoded)); } else { result.parts.add(xmtp.Composite_Part(composite: await _encode(part))); diff --git a/lib/src/content/reaction_codec.dart b/lib/src/content/reaction_codec.dart new file mode 100644 index 0000000..35eb2cb --- /dev/null +++ b/lib/src/content/reaction_codec.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +import 'package:xmtp_proto/xmtp_proto.dart' as xmtp; + +import 'codec.dart'; + +final contentTypeReaction = xmtp.ContentTypeId( + authorityId: "xmtp.org", + typeId: "reaction", + versionMajor: 1, + versionMinor: 0, +); + +enum ReactionAction { + added, + removed, +} + +enum ReactionSchema { + unicode, + shortcode, + custom, +} + +/// This is a reaction to another [reference] message. +class Reaction { + final String reference; + final ReactionAction action; + final ReactionSchema schema; + final String content; + + Reaction(this.reference, this.action, this.schema, this.content); + + String toJson() => json.encode({ + "reference": reference, + "action": action.toString().split(".").last, + "schema": schema.toString().split(".").last, + "content": content, + }); + + static Reaction fromJson(String json) { + var v = jsonDecode(json); + return Reaction( + v["reference"], + ReactionAction.values + .firstWhere((e) => e.toString().split(".").last == v["action"]), + ReactionSchema.values + .firstWhere((e) => e.toString().split(".").last == v["schema"]), + v["content"], + ); + } +} + +/// This is a [Codec] that encodes a reaction to another message. +class ReactionCodec extends Codec { + @override + xmtp.ContentTypeId get contentType => contentTypeReaction; + + @override + Future decode(xmtp.EncodedContent encoded) async => + Reaction.fromJson(utf8.decode(encoded.content)); + + @override + Future encode(Reaction decoded) async => + xmtp.EncodedContent( + type: contentTypeReaction, + content: utf8.encode(decoded.toJson()), + ); +} diff --git a/lib/src/content/reply_codec.dart b/lib/src/content/reply_codec.dart new file mode 100644 index 0000000..ba7ed5b --- /dev/null +++ b/lib/src/content/reply_codec.dart @@ -0,0 +1,50 @@ +import 'package:xmtp_proto/xmtp_proto.dart' as xmtp; + +import 'codec.dart'; +import 'decoded.dart'; + +final contentTypeReply = xmtp.ContentTypeId( + authorityId: "xmtp.org", + typeId: "reply", + versionMajor: 1, + versionMinor: 0, +); + +/// This is a reply to another [reference] message. +class Reply { + /// This is the message ID of the parent message. + /// See [DecodedMessage.id] + final String reference; + final DecodedContent content; + + Reply(this.reference, this.content); +} + +extension on xmtp.ContentTypeId { + String toText() => "$authorityId/$typeId:$versionMajor.$versionMinor"; +} + +/// This is a [Codec] that encodes a reply to another message. +class ReplyCodec extends NestedContentCodec { + + @override + xmtp.ContentTypeId get contentType => contentTypeReply; + + @override + Future decode(xmtp.EncodedContent encoded) async => Reply( + encoded.parameters["reference"] ?? "", + await registry.decode(xmtp.EncodedContent.fromBuffer(encoded.content)), + ); + + @override + Future encode(Reply decoded) async => + xmtp.EncodedContent( + type: contentTypeReply, + parameters: { + "reference": decoded.reference, + // TODO: cut when we know nothing looks here for the content type + "contentType": decoded.content.contentType.toText(), + }, + content: (await registry.encode(decoded.content)).writeToBuffer(), + ); +} diff --git a/test/client_test.dart b/test/client_test.dart index e80a97c..be7e9ca 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; @@ -9,7 +10,11 @@ import 'package:xmtp_proto/xmtp_proto.dart' as xmtp; import 'package:xmtp/src/common/api.dart'; import 'package:xmtp/src/common/signature.dart'; import 'package:xmtp/src/common/topic.dart'; +import 'package:xmtp/src/content/attachment_codec.dart'; import 'package:xmtp/src/content/codec.dart'; +import 'package:xmtp/src/content/decoded.dart'; +import 'package:xmtp/src/content/reaction_codec.dart'; +import 'package:xmtp/src/content/reply_codec.dart'; import 'package:xmtp/src/content/text_codec.dart'; import 'package:xmtp/src/client.dart'; @@ -426,6 +431,134 @@ void main() { }, ); + test( + skip: skipUnlessTestServerEnabled, + "codecs: sending codec encoded message to user", + () async { + var aliceWallet = EthPrivateKey.createRandom(Random.secure()).asSigner(); + var aliceApi = createTestServerApi(); + var alice = await Client.createFromWallet( + aliceApi, + aliceWallet, + customCodecs: [IntegerCodec()], + ); + var bobWallet = EthPrivateKey.createRandom(Random.secure()).asSigner(); + var bobApi = createTestServerApi(); + var bob = await Client.createFromWallet( + bobApi, + bobWallet, + customCodecs: [IntegerCodec()], + ); + var convo = await alice.newConversation(bob.address.hex); + + debugPrint('sending as ${alice.address.hexEip55}'); + + await alice.sendMessage(convo, "Hello!"); + await alice.sendMessage(convo, "Here's a number:"); + await delayToPropagate(); + await alice.sendMessage(convo, 12345, contentType: contentTypeInteger); + await delayToPropagate(); + await alice.sendMessage(convo, "Do you see it up ^ there?"); + await delayToPropagate(); + await alice.sendMessage(convo, "Here's an attachment:"); + await delayToPropagate(); + await alice.sendMessage(convo, + Attachment("foo.txt", "text/plain", utf8.encode("some writing")), + contentType: contentTypeAttachment); + await delayToPropagate(); + await alice.sendMessage(convo, "Do you see it up ^ there?"); + await delayToPropagate(); + + var msg = await alice.sendMessage(convo, "Here's a reaction:"); + await delayToPropagate(); + await alice.sendMessage(convo, + Reaction(msg.id, ReactionAction.added, ReactionSchema.unicode, "👍"), + contentType: contentTypeReaction); + await delayToPropagate(); + await alice.sendMessage(convo, "Do you see it up ^ there?"); + await delayToPropagate(); + + msg = await alice.sendMessage(convo, "Here's a reply:"); + await delayToPropagate(); + await alice.sendMessage( + convo, + Reply(msg.id, + DecodedContent(contentTypeText, "I'm replying to myself!")), + contentType: contentTypeReply); + await delayToPropagate(); + await alice.sendMessage(convo, "Do you see it up ^ there?"); + await delayToPropagate(); + + msg = + await alice.sendMessage(convo, "Here's a reply with an attachment:"); + await delayToPropagate(); + await alice.sendMessage( + convo, + Reply( + msg.id, + DecodedContent( + contentTypeAttachment, + Attachment("reply.txt", "text/plain", + utf8.encode("a lengthy reply" * 100)))), + contentType: contentTypeReply, + ); + await delayToPropagate(); + await alice.sendMessage(convo, "Do you see it up ^ there?"); + await delayToPropagate(); + + var messages = await bob.listMessages(convo, + sort: xmtp.SortDirection.SORT_DIRECTION_ASCENDING); + expect(messages.length, 16); + expect(messages[0].content, "Hello!"); + expect(messages[1].content, "Here's a number:"); + expect(messages[2].content, 12345); + expect(messages[3].content, "Do you see it up ^ there?"); + expect(messages[4].content, "Here's an attachment:"); + expect(messages[5].content, isA()); + expect((messages[5].content as Attachment).mimeType, "text/plain"); + expect((messages[5].content as Attachment).filename, "foo.txt"); + expect((messages[5].content as Attachment).data, + utf8.encode("some writing")); + expect(messages[6].content, "Do you see it up ^ there?"); + expect(messages[7].content, "Here's a reaction:"); + expect(messages[8].content, isA()); + expect((messages[8].content as Reaction).reference, messages[7].id); + expect((messages[8].content as Reaction).action, ReactionAction.added); + expect((messages[8].content as Reaction).schema, ReactionSchema.unicode); + expect((messages[8].content as Reaction).content, "👍"); + expect(messages[9].content, "Do you see it up ^ there?"); + expect(messages[10].content, "Here's a reply:"); + expect(messages[11].content, isA()); + expect((messages[11].content as Reply).reference, messages[10].id); + expect( + (messages[11].content as Reply).content.contentType, + contentTypeText, + ); + expect( + (messages[11].content as Reply).content.content, + "I'm replying to myself!", + ); + expect(messages[12].content, "Do you see it up ^ there?"); + expect(messages[13].content, "Here's a reply with an attachment:"); + expect(messages[14].content, isA()); + expect( + (messages[14].content as Reply).content.content, isA()); + expect( + ((messages[14].content as Reply).content.content as Attachment) + .mimeType, + "text/plain"); + expect( + ((messages[14].content as Reply).content.content as Attachment) + .filename, + "reply.txt"); + expect( + ((messages[14].content as Reply).content.content as Attachment).data, + utf8.encode("a lengthy reply" * 100), + ); + expect(messages[15].content, "Do you see it up ^ there?"); + }, + ); + // This connects to the dev network to test the client. // NOTE: it requires a private key test( diff --git a/test/content/attachment_test.dart b/test/content/attachment_test.dart new file mode 100644 index 0000000..2e8c944 --- /dev/null +++ b/test/content/attachment_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:xmtp/src/content/attachment_codec.dart'; + +void main() { + test('attached file should be encoded and decoded', () async { + var codec = AttachmentCodec(); + + var encoded = await codec.encode( + Attachment("file.bin", "application/octet-stream", [3, 2, 1])); + expect(encoded.type, contentTypeAttachment); + expect(encoded.content.isNotEmpty, true); + var decoded = await codec.decode(encoded); + expect(decoded.filename, "file.bin"); + expect(decoded.mimeType, "application/octet-stream"); + expect(decoded.data, [3, 2, 1]); + }); +} diff --git a/test/content/composite_codec_test.dart b/test/content/composite_codec_test.dart index 7582e55..0581dc3 100644 --- a/test/content/composite_codec_test.dart +++ b/test/content/composite_codec_test.dart @@ -8,7 +8,8 @@ import 'package:xmtp/src/content/text_codec.dart'; void main() { test('single nested string should be encoded/decoded', () async { var registry = CodecRegistry()..registerCodec(TextCodec()); - var codec = CompositeCodec(registry); + var codec = CompositeCodec(); + codec.setRegistry(registry); var encoded = await codec.encode( DecodedComposite.ofContent(DecodedContent(contentTypeText, "foo bar"))); @@ -22,7 +23,8 @@ void main() { test('multiple strings should be encoded/decoded', () async { var registry = CodecRegistry()..registerCodec(TextCodec()); - var codec = CompositeCodec(registry); + var codec = CompositeCodec(); + codec.setRegistry(registry); var encoded = await codec.encode(DecodedComposite.withParts([ DecodedComposite.ofContent(DecodedContent(contentTypeText, "foo")), diff --git a/test/content/reaction_codec_test.dart b/test/content/reaction_codec_test.dart new file mode 100644 index 0000000..89db3df --- /dev/null +++ b/test/content/reaction_codec_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:xmtp/src/content/reaction_codec.dart'; + +void main() { + test('reaction should be encoded and decoded', () async { + var codec = ReactionCodec(); + + var encoded = await codec.encode( + Reaction("abc123", ReactionAction.added, ReactionSchema.unicode, "👍")); + expect(encoded.type, contentTypeReaction); + expect(encoded.content.isNotEmpty, true); + var decoded = await codec.decode(encoded); + expect(decoded.reference, "abc123"); + expect(decoded.action, ReactionAction.added); + expect(decoded.schema, ReactionSchema.unicode); + expect(decoded.content, "👍"); + }); +} diff --git a/test/content/reply_codec_test.dart b/test/content/reply_codec_test.dart new file mode 100644 index 0000000..bd7b815 --- /dev/null +++ b/test/content/reply_codec_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:xmtp/src/content/codec_registry.dart'; +import 'package:xmtp/src/content/decoded.dart'; +import 'package:xmtp/src/content/text_codec.dart'; +import 'package:xmtp/src/content/reply_codec.dart'; + +void main() { + test('reply text should be encoded and decoded', () async { + var registry = CodecRegistry()..registerCodec(TextCodec()); + var codec = ReplyCodec(); + codec.setRegistry(registry); + + var parentMessageId = "abc123"; + var encoded = await codec.encode( + Reply(parentMessageId, DecodedContent(contentTypeText, "foo bar"))); + expect(encoded.type, contentTypeReply); + expect(encoded.content.isNotEmpty, true); + var decoded = await codec.decode(encoded); + expect(decoded.reference, parentMessageId); + expect(decoded.content!.contentType, contentTypeText); + expect(decoded.content!.content, "foo bar"); + }); +}