diff --git a/l10n.yaml b/l10n.yaml index 15338f2..2901215 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,3 +1,5 @@ arb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart +output-dir: lib/l10n/generated +synthetic-package: false diff --git a/lib/api/toxcore/tox_events.dart b/lib/api/toxcore/tox_events.dart index c184c8c..cb36c0d 100644 --- a/lib/api/toxcore/tox_events.dart +++ b/lib/api/toxcore/tox_events.dart @@ -2,17 +2,17 @@ import 'dart:typed_data'; import 'package:btox/ffi/toxcore.dart'; +import 'package:btox/models/crypto.dart'; import 'package:btox/packets/messagepack.dart'; +import 'package:btox/packets/packet.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'tox_events.freezed.dart'; part 'tox_events.g.dart'; -sealed class Event { +sealed class Event extends Packet { const Event(); - void pack(Packer packer); - factory Event.unpack(Unpacker unpacker, Tox_Event_Type type) { switch (type) { case Tox_Event_Type.TOX_EVENT_SELF_CONNECTION_STATUS: @@ -158,7 +158,7 @@ class ToxEventConferenceInvite extends Event with _$ToxEventConferenceInvite { factory ToxEventConferenceInvite.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventConferenceInvite( - cookie: Uint8List.fromList(unpacker.unpackBinary()), + cookie: unpacker.unpackBinary()!, type: Tox_Conference_Type.fromValue(unpacker.unpackInt()!), friendNumber: unpacker.unpackInt()!, ); @@ -195,7 +195,7 @@ class ToxEventConferenceMessage extends Event with _$ToxEventConferenceMessage { factory ToxEventConferenceMessage.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 4); return ToxEventConferenceMessage( - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, type: Tox_Message_Type.fromValue(unpacker.unpackInt()!), conferenceNumber: unpacker.unpackInt()!, peerNumber: unpacker.unpackInt()!, @@ -262,7 +262,7 @@ class ToxEventConferencePeerName extends Event factory ToxEventConferencePeerName.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventConferencePeerName( - name: Uint8List.fromList(unpacker.unpackBinary()), + name: unpacker.unpackBinary()!, conferenceNumber: unpacker.unpackInt()!, peerNumber: unpacker.unpackInt()!, ); @@ -298,7 +298,7 @@ class ToxEventConferenceTitle extends Event with _$ToxEventConferenceTitle { factory ToxEventConferenceTitle.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventConferenceTitle( - title: Uint8List.fromList(unpacker.unpackBinary()), + title: unpacker.unpackBinary()!, conferenceNumber: unpacker.unpackInt()!, peerNumber: unpacker.unpackInt()!, ); @@ -323,7 +323,7 @@ class ToxEventDhtNodesResponse extends Event with _$ToxEventDhtNodesResponse { converters: [Uint8ListConverter()], ) const factory ToxEventDhtNodesResponse({ - required Uint8List publicKey, + required PublicKey publicKey, required Uint8List ip, required int port, }) = _ToxEventDhtNodesResponse; @@ -334,8 +334,8 @@ class ToxEventDhtNodesResponse extends Event with _$ToxEventDhtNodesResponse { factory ToxEventDhtNodesResponse.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventDhtNodesResponse( - publicKey: Uint8List.fromList(unpacker.unpackBinary()), - ip: Uint8List.fromList(unpacker.unpackBinary()), + publicKey: PublicKey.unpack(unpacker), + ip: unpacker.unpackBinary()!, port: unpacker.unpackInt()!, ); } @@ -344,7 +344,7 @@ class ToxEventDhtNodesResponse extends Event with _$ToxEventDhtNodesResponse { void pack(Packer packer) { packer ..packListLength(3) - ..packBinary(publicKey) + ..pack(publicKey) ..packBinary(ip) ..packInt(port); } @@ -410,7 +410,7 @@ class ToxEventFileRecv extends Event with _$ToxEventFileRecv { factory ToxEventFileRecv.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 5); return ToxEventFileRecv( - filename: Uint8List.fromList(unpacker.unpackBinary()), + filename: unpacker.unpackBinary()!, fileNumber: unpacker.unpackInt()!, fileSize: unpacker.unpackInt()!, friendNumber: unpacker.unpackInt()!, @@ -451,7 +451,7 @@ class ToxEventFileRecvChunk extends Event with _$ToxEventFileRecvChunk { factory ToxEventFileRecvChunk.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 4); return ToxEventFileRecvChunk( - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, fileNumber: unpacker.unpackInt()!, friendNumber: unpacker.unpackInt()!, position: unpacker.unpackInt()!, @@ -558,7 +558,7 @@ class ToxEventFriendLosslessPacket extends Event factory ToxEventFriendLosslessPacket.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventFriendLosslessPacket( - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, dataLength: unpacker.unpackInt()!, friendNumber: unpacker.unpackInt()!, ); @@ -594,7 +594,7 @@ class ToxEventFriendLossyPacket extends Event with _$ToxEventFriendLossyPacket { factory ToxEventFriendLossyPacket.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventFriendLossyPacket( - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, dataLength: unpacker.unpackInt()!, friendNumber: unpacker.unpackInt()!, ); @@ -634,7 +634,7 @@ class ToxEventFriendMessage extends Event with _$ToxEventFriendMessage { friendNumber: unpacker.unpackInt()!, type: Tox_Message_Type.fromValue(unpacker.unpackInt()!), messageLength: unpacker.unpackInt()!, - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, ); } @@ -668,7 +668,7 @@ class ToxEventFriendName extends Event with _$ToxEventFriendName { factory ToxEventFriendName.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 2); return ToxEventFriendName( - name: Uint8List.fromList(unpacker.unpackBinary()), + name: unpacker.unpackBinary()!, friendNumber: unpacker.unpackInt()!, ); } @@ -724,7 +724,7 @@ class ToxEventFriendRequest extends Event with _$ToxEventFriendRequest { ) const factory ToxEventFriendRequest({ required Uint8List message, - required Uint8List publicKey, + required PublicKey publicKey, }) = _ToxEventFriendRequest; factory ToxEventFriendRequest.fromJson(Map json) => @@ -733,8 +733,8 @@ class ToxEventFriendRequest extends Event with _$ToxEventFriendRequest { factory ToxEventFriendRequest.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 2); return ToxEventFriendRequest( - message: Uint8List.fromList(unpacker.unpackBinary()), - publicKey: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, + publicKey: PublicKey.unpack(unpacker), ); } @@ -743,7 +743,7 @@ class ToxEventFriendRequest extends Event with _$ToxEventFriendRequest { packer ..packListLength(2) ..packBinary(message) - ..packBinary(publicKey); + ..pack(publicKey); } } @@ -799,7 +799,7 @@ class ToxEventFriendStatusMessage extends Event factory ToxEventFriendStatusMessage.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 2); return ToxEventFriendStatusMessage( - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, friendNumber: unpacker.unpackInt()!, ); } @@ -867,7 +867,7 @@ class ToxEventGroupCustomPacket extends Event with _$ToxEventGroupCustomPacket { return ToxEventGroupCustomPacket( groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, ); } @@ -905,7 +905,7 @@ class ToxEventGroupCustomPrivatePacket extends Event return ToxEventGroupCustomPrivatePacket( groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, ); } @@ -940,8 +940,8 @@ class ToxEventGroupInvite extends Event with _$ToxEventGroupInvite { ensure(unpacker.unpackListLength(), 3); return ToxEventGroupInvite( friendNumber: unpacker.unpackInt()!, - inviteData: Uint8List.fromList(unpacker.unpackBinary()), - groupName: Uint8List.fromList(unpacker.unpackBinary()), + inviteData: unpacker.unpackBinary()!, + groupName: unpacker.unpackBinary()!, ); } @@ -1012,7 +1012,7 @@ class ToxEventGroupMessage extends Event with _$ToxEventGroupMessage { groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, messageType: Tox_Message_Type.fromValue(unpacker.unpackInt()!), - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, messageId: unpacker.unpackInt()!, ); } @@ -1087,7 +1087,7 @@ class ToxEventGroupPassword extends Event with _$ToxEventGroupPassword { ensure(unpacker.unpackListLength(), 2); return ToxEventGroupPassword( groupNumber: unpacker.unpackInt()!, - password: Uint8List.fromList(unpacker.unpackBinary()), + password: unpacker.unpackBinary()!, ); } @@ -1125,8 +1125,8 @@ class ToxEventGroupPeerExit extends Event with _$ToxEventGroupPeerExit { groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, exitType: Tox_Group_Exit_Type.fromValue(unpacker.unpackInt()!), - name: Uint8List.fromList(unpacker.unpackBinary()), - partMessage: Uint8List.fromList(unpacker.unpackBinary()), + name: unpacker.unpackBinary()!, + partMessage: unpacker.unpackBinary()!, ); } @@ -1228,7 +1228,7 @@ class ToxEventGroupPeerName extends Event with _$ToxEventGroupPeerName { return ToxEventGroupPeerName( groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, - name: Uint8List.fromList(unpacker.unpackBinary()), + name: unpacker.unpackBinary()!, ); } @@ -1335,7 +1335,7 @@ class ToxEventGroupPrivateMessage extends Event groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, messageType: Tox_Message_Type.fromValue(unpacker.unpackInt()!), - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, messageId: unpacker.unpackInt()!, ); } @@ -1400,7 +1400,7 @@ class ToxEventGroupTopic extends Event with _$ToxEventGroupTopic { return ToxEventGroupTopic( groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, - topic: Uint8List.fromList(unpacker.unpackBinary()), + topic: unpacker.unpackBinary()!, ); } diff --git a/lib/btox_app.dart b/lib/btox_app.dart index 51ab61a..1b066af 100644 --- a/lib/btox_app.dart +++ b/lib/btox_app.dart @@ -1,5 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/pages/contact_list_page.dart'; import 'package:btox/pages/create_profile_page.dart'; import 'package:btox/pages/select_profile_page.dart'; @@ -7,7 +8,6 @@ import 'package:btox/providers/database.dart'; import 'package:btox/providers/sodium.dart'; import 'package:btox/providers/tox.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sodium/sodium.dart'; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 50b5393..6f03005 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16,10 +16,20 @@ "@message": { "description": "The text for the message field" }, - "messageInput": "message", + "messageInput": "Tox message", "@messageInput": { "description": "The placeholder text for the message input field" }, + "re": "Re: {message}", + "@re": { + "description": "The prefix for a reply message", + "placeholders": { + "message": { + "type": "String", + "example": "Hello" + } + } + }, "toxId": "Tox ID", "@toxId": { "description": "The text for the Tox ID field" diff --git a/lib/models/crypto.dart b/lib/models/crypto.dart index e0ade57..b6bc101 100644 --- a/lib/models/crypto.dart +++ b/lib/models/crypto.dart @@ -1,4 +1,6 @@ import 'package:btox/models/bytes.dart'; +import 'package:btox/packets/messagepack.dart'; +import 'package:btox/packets/packet.dart'; import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:drift/drift.dart'; @@ -18,7 +20,7 @@ final class NospamConverter extends TypeConverter int toSql(ToxAddressNospam value) => toJson(value); } -final class PublicKey extends _CryptoBytes { +final class PublicKey extends _CryptoBytes with Packet { static const kLength = 32; PublicKey(super.bytes); @@ -27,8 +29,17 @@ final class PublicKey extends _CryptoBytes { return PublicKey(Uint8List.fromList(hex.decode(value))); } + factory PublicKey.unpack(Unpacker unpacker) { + return PublicKey(unpacker.unpackBinary()!); + } + @override int get length => PublicKey.kLength; + + @override + void pack(Packer packer) { + packer.packBinary(bytes); + } } final class PublicKeyConverter extends TypeConverter @@ -50,12 +61,12 @@ final class SecretKey extends _CryptoBytes { SecretKey(super.bytes); - factory SecretKey.fromSodium(SecureKey value) { - return SecretKey(Uint8List.fromList(value.extractBytes())); + factory SecretKey.fromJson(String value) { + return SecretKey(Uint8List.fromList(hex.decode(value))); } - factory SecretKey.fromString(String value) { - return SecretKey(Uint8List.fromList(hex.decode(value))); + factory SecretKey.fromSodium(SecureKey value) { + return SecretKey(Uint8List.fromList(value.extractBytes())); } @override @@ -67,7 +78,7 @@ final class SecretKeyConverter extends TypeConverter const SecretKeyConverter(); @override - SecretKey fromJson(String json) => SecretKey.fromString(json); + SecretKey fromJson(String json) => SecretKey.fromJson(json); @override SecretKey fromSql(String fromDb) => fromJson(fromDb); @override @@ -76,7 +87,7 @@ final class SecretKeyConverter extends TypeConverter String toSql(SecretKey value) => toJson(value); } -final class Sha256 extends _CryptoBytes { +final class Sha256 extends _CryptoBytes with Packet { static const kLength = 32; Sha256(super.bytes); @@ -91,6 +102,11 @@ final class Sha256 extends _CryptoBytes { @override int get length => Sha256.kLength; + + @override + void pack(Packer packer) { + packer.packBinary(bytes); + } } final class Sha256Converter extends TypeConverter @@ -148,27 +164,6 @@ final class ToxAddressNospam extends _CryptoNumber { } } -sealed class _CryptoNumber { - final int value; - - const _CryptoNumber(this.value); - - @override - int get hashCode => value; - - @override - bool operator ==(Object other) { - return other is _CryptoNumber && - other.runtimeType == runtimeType && - other.value == value; - } - - @override - String toString() { - return '$runtimeType($value)'; - } -} - sealed class _CryptoBytes { final Uint8List bytes; @@ -199,3 +194,24 @@ sealed class _CryptoBytes { return '$runtimeType(${toJson()})'; } } + +sealed class _CryptoNumber { + final int value; + + const _CryptoNumber(this.value); + + @override + int get hashCode => value; + + @override + bool operator ==(Object other) { + return other is _CryptoNumber && + other.runtimeType == runtimeType && + other.value == value; + } + + @override + String toString() { + return '$runtimeType($value)'; + } +} diff --git a/lib/models/messaging.dart b/lib/models/messaging.dart index f1f3b75..b6e4976 100644 --- a/lib/models/messaging.dart +++ b/lib/models/messaging.dart @@ -1,45 +1,40 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:btox/db/database.dart'; import 'package:btox/models/crypto.dart'; import 'package:btox/models/id.dart'; import 'package:btox/models/persistence.dart'; -import 'package:btox/packets/messagepack.dart'; +import 'package:btox/packets/message_packet.dart'; import 'package:crypto/crypto.dart'; -// [parent, merge, timestamp, origin, content] -Uint8List encodeMessage(Message? parent, Message? merge, DateTime timestamp, - PublicKey origin, String content) { - final Packer packer = Packer(); - - packer - ..packListLength(5) - ..packBinary(parent?.sha.bytes) - ..packBinary(merge?.sha.bytes) - ..packInt(timestamp.millisecondsSinceEpoch) - ..packBinary(origin.bytes) - ..packBinary(utf8.encode(content)); - - return packer.takeBytes(); +// [parent, merged, timestamp, author, content] +Uint8List encodeMessage(Message? parent, Message? merged, DateTime timestamp, + PublicKey author, String content) { + return MessagePacket( + parent: parent?.sha, + merged: merged?.sha, + timestamp: timestamp, + author: author, + content: content, + ).encode(); } MessagesCompanion newMessage({ required Id contactId, required Message? parent, - Message? merged, - required PublicKey origin, + required Message? merged, + required PublicKey author, required DateTime timestamp, required String content, }) { return MessagesCompanion.insert( contactId: contactId, parent: Value.absentIfNull(parent?.id), - origin: origin, + author: author, timestamp: timestamp, content: content, sha: Sha256.fromDigest( - sha256.convert(encodeMessage(parent, merged, timestamp, origin, content)), + sha256.convert(encodeMessage(parent, merged, timestamp, author, content)), ), ); } diff --git a/lib/models/persistence.dart b/lib/models/persistence.dart index 25eb4a1..c381bbd 100644 --- a/lib/models/persistence.dart +++ b/lib/models/persistence.dart @@ -21,7 +21,7 @@ abstract class Messages extends Table { IntColumn get contactId => integer().references(Contacts, #id).map(const IdConverter())(); - TextColumn get origin => text().map(const PublicKeyConverter())(); + TextColumn get author => text().map(const PublicKeyConverter())(); // The SHA-256 hash of the message content, timestamp, and parent/merge hashes. TextColumn get sha => text() @@ -31,7 +31,7 @@ abstract class Messages extends Table { .references(Messages, #id) .map(const IdConverter()) .nullable()(); - IntColumn get merge => integer() + IntColumn get merged => integer() .references(Messages, #id) .map(const IdConverter()) .nullable()(); diff --git a/lib/packets/message_packet.dart b/lib/packets/message_packet.dart new file mode 100644 index 0000000..487d042 --- /dev/null +++ b/lib/packets/message_packet.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:btox/models/crypto.dart'; +import 'package:btox/packets/messagepack.dart'; +import 'package:btox/packets/packet.dart'; +import 'package:crypto/crypto.dart'; + +final class MessagePacket extends Packet { + final Sha256? parent; + final Sha256? merged; + final DateTime timestamp; + final PublicKey author; + final String content; + + const MessagePacket({ + required this.parent, + required this.merged, + required this.timestamp, + required this.author, + required this.content, + }); + + factory MessagePacket.unpack(Unpacker unpacker) { + final int length = unpacker.unpackListLength(); + if (length != 5) { + throw Exception('Invalid message packet'); + } + + final Sha256? parent = + unpacker.unpackBinary()?.let((sha) => Sha256.fromDigest(Digest(sha))); + final Sha256? merged = + unpacker.unpackBinary()?.let((sha) => Sha256.fromDigest(Digest(sha))); + final DateTime timestamp = + DateTime.fromMillisecondsSinceEpoch(unpacker.unpackInt()!); + final PublicKey author = PublicKey.unpack(unpacker); + final String content = utf8.decode(unpacker.unpackBinary()!); + + return MessagePacket( + parent: parent, + merged: merged, + timestamp: timestamp, + author: author, + content: content, + ); + } + + @override + void pack(Packer packer) { + packer + ..packListLength(5) + ..packBinary(parent?.bytes) + ..packBinary(merged?.bytes) + ..packInt(timestamp.millisecondsSinceEpoch) + ..packBinary(author.bytes) + ..packBinary(utf8.encode(content)); + } +} + +extension on Uint8List { + T let(T Function(Uint8List) f) { + return f(this); + } +} diff --git a/lib/packets/messagepack/packer.dart b/lib/packets/messagepack/packer.dart index 58bc938..21abd9f 100644 --- a/lib/packets/messagepack/packer.dart +++ b/lib/packets/messagepack/packer.dart @@ -4,6 +4,9 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:btox/packets/messagepack/tags.dart'; +import 'package:btox/packets/packet.dart'; + // dart2js doesn't support 64 bit ints, so we pack using 2x 32 bit ints. void _setUint64(ByteData d, int offset, int v) { d.setUint32(offset, v >> 32); @@ -25,7 +28,7 @@ void _setInt64(ByteData d, int offset, int v) { /// Streaming packing requires buffer to collect your data. /// Try to figure out the best initial size of this buffer, that minimal enough to fit your most common data packing scenario. /// Try to find balance. Provide this value in constructor [Packer()] -class Packer { +final class Packer { /// Provide the [_bufSize] size, that minimal enough to fit your most used data packets. /// Try to find balance, small buffer is good, and if most of your data will fit to it, performance will be good. /// If buffer not enough it will be increased automatically. @@ -82,7 +85,7 @@ class Packer { /// Other packXXX implicitly handle null values. void packNull() { if (_buf.length - _offset < 1) _nextBuf(); - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } /// Pack [bool] or `null`. @@ -90,9 +93,9 @@ class Packer { void packBool(bool? v) { if (_buf.length - _offset < 1) _nextBuf(); if (v == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } else { - _d.setUint8(_offset++, v ? 0xc3 : 0xc2); + _d.setUint8(_offset++, v ? kTagTrue : kTagFalse); } } @@ -101,41 +104,41 @@ class Packer { // max 8 byte int + 1 control byte if (_buf.length - _offset < 9) _nextBuf(); if (v == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } else if (v >= 0) { if (v <= 127) { _d.setUint8(_offset++, v); } else if (v <= 0xFF) { - _d.setUint8(_offset++, 0xcc); + _d.setUint8(_offset++, kTagUint8); _d.setUint8(_offset++, v); } else if (v <= 0xFFFF) { - _d.setUint8(_offset++, 0xcd); + _d.setUint8(_offset++, kTagUint16); _d.setUint16(_offset, v); _offset += 2; } else if (v <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xce); + _d.setUint8(_offset++, kTagUint32); _d.setUint32(_offset, v); _offset += 4; } else { - _d.setUint8(_offset++, 0xcf); + _d.setUint8(_offset++, kTagUint64); _setUint64(_d, _offset, v); _offset += 8; } } else if (v >= -32) { _d.setInt8(_offset++, v); } else if (v >= -128) { - _d.setUint8(_offset++, 0xd0); + _d.setUint8(_offset++, kTagInt8); _d.setInt8(_offset++, v); } else if (v >= -32768) { - _d.setUint8(_offset++, 0xd1); + _d.setUint8(_offset++, kTagInt16); _d.setInt16(_offset, v); _offset += 2; } else if (v >= -2147483648) { - _d.setUint8(_offset++, 0xd2); + _d.setUint8(_offset++, kTagInt32); _d.setInt32(_offset, v); _offset += 4; } else { - _d.setUint8(_offset++, 0xd3); + _d.setUint8(_offset++, kTagInt64); _setInt64(_d, _offset, v); _offset += 8; } @@ -146,10 +149,10 @@ class Packer { // 8 byte double + 1 control byte if (_buf.length - _offset < 9) _nextBuf(); if (v == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); return; } - _d.setUint8(_offset++, 0xcb); + _d.setUint8(_offset++, kTagFloat64); _d.setFloat64(_offset, v); _offset += 8; } @@ -169,7 +172,7 @@ class Packer { // max 4 byte str header + 1 control byte if (_buf.length - _offset < 5) _nextBuf(); if (v == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); return; } final encoded = _strCodec.encode(v); @@ -177,14 +180,14 @@ class Packer { if (length <= 31) { _d.setUint8(_offset++, 0xA0 | length); } else if (length <= 0xFF) { - _d.setUint8(_offset++, 0xd9); + _d.setUint8(_offset++, kTagStr8); _d.setUint8(_offset++, length); } else if (length <= 0xFFFF) { - _d.setUint8(_offset++, 0xda); + _d.setUint8(_offset++, kTagStr16); _d.setUint16(_offset, length); _offset += 2; } else if (length <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xdb); + _d.setUint8(_offset++, kTagStr32); _d.setUint32(_offset, length); _offset += 4; } else { @@ -210,19 +213,19 @@ class Packer { // max 4 byte binary header + 1 control byte if (_buf.length - _offset < 5) _nextBuf(); if (buffer == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); return; } final length = buffer.length; if (length <= 0xFF) { - _d.setUint8(_offset++, 0xc4); + _d.setUint8(_offset++, kTagBin8); _d.setUint8(_offset++, length); } else if (length <= 0xFFFF) { - _d.setUint8(_offset++, 0xc5); + _d.setUint8(_offset++, kTagBin16); _d.setUint16(_offset, length); _offset += 2; } else if (length <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xc6); + _d.setUint8(_offset++, kTagBin32); _d.setUint32(_offset, length); _offset += 4; } else { @@ -236,15 +239,15 @@ class Packer { // max 4 length header + 1 control byte if (_buf.length - _offset < 5) _nextBuf(); if (length == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } else if (length <= 0xF) { _d.setUint8(_offset++, 0x90 | length); } else if (length <= 0xFFFF) { - _d.setUint8(_offset++, 0xdc); + _d.setUint8(_offset++, kTagArray16); _d.setUint16(_offset, length); _offset += 2; } else if (length <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xdd); + _d.setUint8(_offset++, kTagArray32); _d.setUint32(_offset, length); _offset += 4; } else { @@ -257,15 +260,15 @@ class Packer { // max 4 byte header + 1 control byte if (_buf.length - _offset < 5) _nextBuf(); if (length == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } else if (length <= 0xF) { _d.setUint8(_offset++, 0x80 | length); } else if (length <= 0xFFFF) { - _d.setUint8(_offset++, 0xde); + _d.setUint8(_offset++, kTagMap16); _d.setUint16(_offset, length); _offset += 2; } else if (length <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xdf); + _d.setUint8(_offset++, kTagMap32); _d.setUint32(_offset, length); _offset += 4; } else { @@ -273,6 +276,10 @@ class Packer { } } + void pack(Packet packet) { + packet.pack(this); + } + /// Get bytes representation of this packer. /// Note: after this call do not reuse packer - create new. Uint8List takeBytes() { diff --git a/lib/packets/messagepack/tags.dart b/lib/packets/messagepack/tags.dart new file mode 100644 index 0000000..13ba65c --- /dev/null +++ b/lib/packets/messagepack/tags.dart @@ -0,0 +1,31 @@ +const kTagNil = 0xc0; // 11000000 +const kTagFalse = 0xc2; // 11000010 +const kTagTrue = 0xc3; // 11000011 +const kTagBin8 = 0xc4; // 11000100 +const kTagBin16 = 0xc5; // 11000101 +const kTagBin32 = 0xc6; // 11000110 +const kTagExt8 = 0xc7; // 11000111 +const kTagExt16 = 0xc8; // 11001000 +const kTagExt32 = 0xc9; // 11001001 +const kTagFloat32 = 0xca; // 11001010 +const kTagFloat64 = 0xcb; // 11001011 +const kTagUint8 = 0xcc; // 11001100 +const kTagUint16 = 0xcd; // 11001101 +const kTagUint32 = 0xce; // 11001110 +const kTagUint64 = 0xcf; // 11001111 +const kTagInt8 = 0xd0; // 11010000 +const kTagInt16 = 0xd1; // 11010001 +const kTagInt32 = 0xd2; // 11010010 +const kTagInt64 = 0xd3; // 11010011 +const kTagFixext1 = 0xd4; // 11010100 +const kTagFixext2 = 0xd5; // 11010101 +const kTagFixext4 = 0xd6; // 11010110 +const kTagFixext8 = 0xd7; // 11010111 +const kTagFixext16 = 0xd8; // 11011000 +const kTagStr8 = 0xd9; // 11011001 +const kTagStr16 = 0xda; // 11011010 +const kTagStr32 = 0xdb; // 11011011 +const kTagArray16 = 0xdc; // 11011100 +const kTagArray32 = 0xdd; // 11011101 +const kTagMap16 = 0xde; // 11011110 +const kTagMap32 = 0xdf; // 11011111 diff --git a/lib/packets/messagepack/unpacker.dart b/lib/packets/messagepack/unpacker.dart index e30eb9f..83aaf36 100644 --- a/lib/packets/messagepack/unpacker.dart +++ b/lib/packets/messagepack/unpacker.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:btox/packets/messagepack/tags.dart'; + // dart2js doesn't support 64 bit ints, so we unpack using 2x 32 bit ints. int _getUint64(ByteData d, int offset) => (d.getUint32(offset) << 32) | d.getUint32(offset + 4); @@ -17,9 +19,9 @@ int _getInt64(ByteData d, int offset) => /// Throws [FormatException] if value is not an requested type, /// but in that case throwing exception not corrupt internal state, /// so other unpackXXX methods can be called after that. -class Unpacker { +final class Unpacker { /// Manipulates with provided [Uint8List] to sequentially unpack values. - /// Use [Unpaker.fromList()] to unpack raw `List` bytes. + /// Use [Unpacker.fromList()] to unpack raw `List` bytes. Unpacker(this._list) : _d = ByteData.view(_list.buffer, _list.offsetInBytes); ///Convenient @@ -29,7 +31,14 @@ class Unpacker { final ByteData _d; int _offset = 0; - final _strCodec = const Utf8Codec(); + bool get hasNull => _d.getUint8(_offset) == kTagNil; + + void unpackNull() { + if (!hasNull) { + throw _formatException('null', _d.getUint8(_offset)); + } + _offset += 1; + } /// Unpack value if it exist. Otherwise returns `null`. /// @@ -37,13 +46,13 @@ class Unpacker { bool? unpackBool() { final b = _d.getUint8(_offset); bool? v; - if (b == 0xc2) { + if (b == kTagFalse) { v = false; _offset += 1; - } else if (b == 0xc3) { + } else if (b == kTagTrue) { v = true; _offset += 1; - } else if (b == 0xc0) { + } else if (b == kTagNil) { v = null; _offset += 1; } else { @@ -62,31 +71,31 @@ class Unpacker { /// Int value in fixnum range [-32..127] encoded in header 1 byte v = _d.getInt8(_offset); _offset += 1; - } else if (b == 0xcc) { + } else if (b == kTagUint8) { v = _d.getUint8(++_offset); _offset += 1; - } else if (b == 0xcd) { + } else if (b == kTagUint16) { v = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xce) { + } else if (b == kTagUint32) { v = _d.getUint32(++_offset); _offset += 4; - } else if (b == 0xcf) { + } else if (b == kTagUint64) { v = _getUint64(_d, ++_offset); _offset += 8; - } else if (b == 0xd0) { + } else if (b == kTagInt8) { v = _d.getInt8(++_offset); _offset += 1; - } else if (b == 0xd1) { + } else if (b == kTagInt16) { v = _d.getInt16(++_offset); _offset += 2; - } else if (b == 0xd2) { + } else if (b == kTagInt32) { v = _d.getInt32(++_offset); _offset += 4; - } else if (b == 0xd3) { + } else if (b == kTagInt64) { v = _getInt64(_d, ++_offset); _offset += 8; - } else if (b == 0xc0) { + } else if (b == kTagNil) { v = null; _offset += 1; } else { @@ -101,13 +110,13 @@ class Unpacker { double? unpackDouble() { final b = _d.getUint8(_offset); double? v; - if (b == 0xca) { + if (b == kTagFloat32) { v = _d.getFloat32(++_offset); _offset += 4; - } else if (b == 0xcb) { + } else if (b == kTagFloat64) { v = _d.getFloat64(++_offset); _offset += 8; - } else if (b == 0xc0) { + } else if (b == kTagNil) { v = null; _offset += 1; } else { @@ -122,26 +131,26 @@ class Unpacker { /// Throws [FormatException] if value is not a String. String? unpackString() { final b = _d.getUint8(_offset); - if (b == 0xc0) { + if (b == kTagNil) { _offset += 1; return null; } int len; - /// fixstr 101XXXXX stores a byte array whose len is upto 31 bytes: + /// fixstr 101XXXXX stores a byte array whose len is up to 31 bytes: if (b & 0xE0 == 0xA0) { len = b & 0x1F; _offset += 1; - } else if (b == 0xc0) { + } else if (b == kTagNil) { _offset += 1; return null; - } else if (b == 0xd9) { + } else if (b == kTagStr8) { len = _d.getUint8(++_offset); _offset += 1; - } else if (b == 0xda) { + } else if (b == kTagStr16) { len = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xdb) { + } else if (b == kTagStr32) { len = _d.getUint32(++_offset); _offset += 4; } else { @@ -150,7 +159,7 @@ class Unpacker { final data = Uint8List.view(_list.buffer, _list.offsetInBytes + _offset, len); _offset += len; - return _strCodec.decode(data); + return utf8.decode(data); } /// Unpack [List.length] if packed value is an [List] or `null`. @@ -162,16 +171,16 @@ class Unpacker { final b = _d.getUint8(_offset); int len; if (b & 0xF0 == 0x90) { - /// fixarray 1001XXXX stores an array whose length is upto 15 elements: + /// fixarray 1001XXXX stores an array whose length is up to 15 elements: len = b & 0xF; _offset += 1; - } else if (b == 0xc0) { + } else if (b == kTagNil) { len = 0; _offset += 1; - } else if (b == 0xdc) { + } else if (b == kTagArray16) { len = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xdd) { + } else if (b == kTagArray32) { len = _d.getUint32(++_offset); _offset += 4; } else { @@ -189,16 +198,16 @@ class Unpacker { final b = _d.getUint8(_offset); int len; if (b & 0xF0 == 0x80) { - /// fixmap 1000XXXX stores a map whose length is upto 15 elements + /// fixmap 1000XXXX stores a map whose length is up to 15 elements len = b & 0xF; _offset += 1; - } else if (b == 0xc0) { + } else if (b == kTagNil) { len = 0; _offset += 1; - } else if (b == 0xde) { + } else if (b == kTagMap16) { len = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xdf) { + } else if (b == kTagMap32) { len = _d.getUint32(++_offset); _offset += 4; } else { @@ -211,19 +220,19 @@ class Unpacker { /// /// Encoded in msgpack packet null unpacks to [List] with 0 length for convenience. /// Throws [FormatException] if value is not a binary. - List unpackBinary() { + Uint8List? unpackBinary() { final b = _d.getUint8(_offset); int len; - if (b == 0xc4) { - len = _d.getUint8(++_offset); + if (b == kTagNil) { _offset += 1; - } else if (b == 0xc0) { - len = 0; + return null; + } else if (b == kTagBin8) { + len = _d.getUint8(++_offset); _offset += 1; - } else if (b == 0xc5) { + } else if (b == kTagBin16) { len = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xc6) { + } else if (b == kTagBin32) { len = _d.getUint32(++_offset); _offset += 4; } else { @@ -232,37 +241,37 @@ class Unpacker { final data = Uint8List.view(_list.buffer, _list.offsetInBytes + _offset, len); _offset += len; - return data.toList(); + return data.asUnmodifiableView(); } Object? _unpack() { final b = _d.getUint8(_offset); if (b <= 0x7f || b >= 0xe0 || - b == 0xcc || - b == 0xcd || - b == 0xce || - b == 0xcf || - b == 0xd0 || - b == 0xd1 || - b == 0xd2 || - b == 0xd3) { + b == kTagUint8 || + b == kTagUint16 || + b == kTagUint32 || + b == kTagUint64 || + b == kTagInt8 || + b == kTagInt16 || + b == kTagInt32 || + b == kTagInt64) { return unpackInt(); - } else if (b == 0xc0 || b == 0xc2 || b == 0xc3) { + } else if (b == kTagNil || b == kTagFalse || b == kTagTrue) { return unpackBool(); //null included - } else if (b == 0xca || b == 0xcb) { + } else if (b == kTagFloat32 || b == kTagFloat64) { return unpackDouble(); } else if ((b & 0xE0) == 0xA0 || - b == 0xc0 || - b == 0xd9 || - b == 0xda || - b == 0xdb) { + b == kTagNil || + b == kTagStr8 || + b == kTagStr16 || + b == kTagStr32) { return unpackString(); - } else if (b == 0xc4 || b == 0xc5 || b == 0xc6) { + } else if (b == kTagBin8 || b == kTagBin16 || b == kTagBin32) { return unpackBinary(); - } else if ((b & 0xF0) == 0x90 || b == 0xdc || b == 0xdd) { + } else if ((b & 0xF0) == 0x90 || b == kTagArray16 || b == kTagArray32) { return unpackList(); - } else if ((b & 0xF0) == 0x80 || b == 0xde || b == 0xdf) { + } else if ((b & 0xF0) == 0x80 || b == kTagMap16 || b == kTagMap32) { return unpackMap(); } else { throw _formatException('Unknown', b); diff --git a/lib/packets/packet.dart b/lib/packets/packet.dart new file mode 100644 index 0000000..37f9de8 --- /dev/null +++ b/lib/packets/packet.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +import 'package:btox/packets/messagepack.dart'; + +abstract mixin class Packet { + const Packet(); + + Uint8List encode() { + final Packer packer = Packer(); + pack(packer); + return packer.takeBytes(); + } + + void pack(Packer packer); +} diff --git a/lib/pages/add_contact_page.dart b/lib/pages/add_contact_page.dart index ca7a934..24d976d 100644 --- a/lib/pages/add_contact_page.dart +++ b/lib/pages/add_contact_page.dart @@ -1,8 +1,8 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/widgets/friend_request_message_field.dart'; import 'package:btox/widgets/tox_id_field.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; final class AddContactPage extends HookWidget { diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 8e7b40c..b93ecb8 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,24 +1,29 @@ import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; +import 'package:btox/widgets/attachment_selector.dart'; +import 'package:btox/widgets/chat_bubble.dart'; +import 'package:btox/widgets/message_input.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; final class ChatPage extends HookWidget { + final Profile profile; final Stream contact; final Stream> messages; - final void Function(Message? parent, String message) onSendMessage; + final void Function(Message? parent, String message)? onSendMessage; const ChatPage({ super.key, + required this.profile, required this.contact, required this.messages, - required this.onSendMessage, + this.onSendMessage, }); @override Widget build(BuildContext context) { - final messageInputController = useTextEditingController(); - final messageInputFocus = useFocusNode(); + final replyingTo = useState(null); + final selectingAttachment = useState(false); return StreamBuilder( stream: contact, @@ -30,7 +35,7 @@ final class ChatPage extends HookWidget { body: StreamBuilder>( stream: messages, builder: ((context, snapshot) { - final messages = snapshot.data ?? []; + final messages = snapshot.data ?? const []; return Column( children: [ Expanded( @@ -40,40 +45,42 @@ final class ChatPage extends HookWidget { itemBuilder: (context, index) { final reversedIndex = messages.length - index - 1; final message = messages[reversedIndex]; - return ListTile( - title: Text(message.content), - subtitle: Text(message.timestamp.toLocal().toString()), + return ChatBubble( + message: message, + isSender: message.author == profile.publicKey, + onReply: () { + replyingTo.value = message; + }, ); }, ), ), Padding( padding: const EdgeInsets.all(16), - child: TextField( - key: const Key('messageField'), - decoration: InputDecoration( - border: const UnderlineInputBorder(), - labelText: AppLocalizations.of(context)!.messageInput, - suffixIcon: IconButton( - onPressed: () => _onSendMessage( - messages.lastOrNull, - messageInputController, - messageInputFocus, - ), - icon: const Icon(Icons.send), - ), - ), - onEditingComplete: () => _onSendMessage( - messages.lastOrNull, - messageInputController, - messageInputFocus, - ), - controller: messageInputController, - focusNode: messageInputFocus, - textInputAction: TextInputAction.send, - autofocus: true, + child: MessageInput( + hintText: AppLocalizations.of(context)!.messageInput, + replyingTo: replyingTo.value?.content ?? '', + onAdd: () { + selectingAttachment.value = !selectingAttachment.value; + }, + onSend: (message) { + onSendMessage?.call( + messages.lastOrNull, + message, + ); + replyingTo.value = null; + }, + onTapCloseReply: () { + replyingTo.value = null; + }, ), ), + if (selectingAttachment.value) + AttachmentSelector( + onAdd: () { + selectingAttachment.value = false; + }, + ), ], ); }), @@ -81,14 +88,4 @@ final class ChatPage extends HookWidget { ), ); } - - void _onSendMessage( - Message? parent, - TextEditingController messageInputController, - FocusNode messageInputFocus, - ) { - onSendMessage(parent, messageInputController.text); - messageInputController.clear(); - messageInputFocus.requestFocus(); - } } diff --git a/lib/pages/contact_list_page.dart b/lib/pages/contact_list_page.dart index 2520566..2980963 100644 --- a/lib/pages/contact_list_page.dart +++ b/lib/pages/contact_list_page.dart @@ -1,5 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/logger.dart'; import 'package:btox/models/crypto.dart'; import 'package:btox/models/messaging.dart'; @@ -10,7 +11,6 @@ import 'package:btox/widgets/contact_list_item.dart'; import 'package:btox/widgets/main_menu.dart'; import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; const _logger = Logger(['ContactListPage']); @@ -78,13 +78,15 @@ final class ContactListPage extends ConsumerWidget { context, MaterialPageRoute( builder: (context) => ChatPage( + profile: profile, contact: database.watchContact(contact.id), messages: database.watchMessagesFor(contact.id), onSendMessage: (Message? parent, String message) { database.addMessage(newMessage( contactId: contact.id, parent: parent, - origin: profile.publicKey, + merged: null, + author: profile.publicKey, timestamp: clock.now().toUtc(), content: message, )); diff --git a/lib/pages/create_profile_page.dart b/lib/pages/create_profile_page.dart index 416bb5a..ae76013 100644 --- a/lib/pages/create_profile_page.dart +++ b/lib/pages/create_profile_page.dart @@ -1,5 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/logger.dart'; import 'package:btox/models/crypto.dart'; import 'package:btox/models/id.dart'; @@ -8,7 +9,6 @@ import 'package:btox/models/profile_settings.dart'; import 'package:btox/widgets/nickname_field.dart'; import 'package:btox/widgets/status_message_field.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:sodium/sodium.dart'; diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 20dc91b..dd700e3 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,7 +1,7 @@ import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/logger.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; const _logger = Logger(['SettingsPage']); diff --git a/lib/pages/user_profile_page.dart b/lib/pages/user_profile_page.dart index 337d2f6..8579023 100644 --- a/lib/pages/user_profile_page.dart +++ b/lib/pages/user_profile_page.dart @@ -1,10 +1,10 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/models/profile_settings.dart'; import 'package:btox/widgets/nickname_field.dart'; import 'package:btox/widgets/status_message_field.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; final class UserProfilePage extends HookWidget { diff --git a/lib/providers/tox.dart b/lib/providers/tox.dart index 7745e99..8d39c64 100644 --- a/lib/providers/tox.dart +++ b/lib/providers/tox.dart @@ -44,9 +44,11 @@ Stream toxEvents( .nodes .where((node) => node.tcpPorts.isNotEmpty) .toList(growable: false); - _logger.d('Got ${nodes.length} bootstrap nodes; using 8...'); + final selectedNodes = nodes.take(8); + _logger.d('Got ${nodes.length} bootstrap nodes; ' + 'using ${selectedNodes.length}...'); try { - for (final node in nodes.take(8)) { + for (final node in selectedNodes) { tox.bootstrap(node.ipv4, node.port, node.publicKey); tox.addTcpRelay(node.ipv4, node.tcpPorts.first, node.publicKey); } diff --git a/lib/widgets/attachment_button.dart b/lib/widgets/attachment_button.dart new file mode 100644 index 0000000..a8fdd16 --- /dev/null +++ b/lib/widgets/attachment_button.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +final class AttachmentButton extends StatelessWidget { + final IconData icon; + final String text; + final void Function() onPressed; + + const AttachmentButton({ + super.key, + required this.icon, + required this.text, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + IconButton( + icon: Icon(icon, size: 56), + onPressed: onPressed, + ), + Text(text), + ], + ); + } +} diff --git a/lib/widgets/attachment_selector.dart b/lib/widgets/attachment_selector.dart new file mode 100644 index 0000000..0056dfd --- /dev/null +++ b/lib/widgets/attachment_selector.dart @@ -0,0 +1,86 @@ +import 'package:btox/logger.dart'; +import 'package:btox/widgets/attachment_button.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +const _logger = Logger(['AttachmentSelector']); + +final class AttachmentSelector extends StatelessWidget { + final void Function() onAdd; + + const AttachmentSelector({ + super.key, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Table( + children: [ + TableRow( + children: [ + AttachmentButton( + icon: Icons.camera_alt, + text: 'Camera', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Camera not implemented'), + ), + ); + }, + ), + AttachmentButton( + icon: Icons.photo, + text: 'Gallery', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gallery not implemented'), + ), + ); + }, + ), + AttachmentButton( + icon: Icons.mic, + text: 'Audio', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Audio not implemented'), + ), + ); + }, + ), + AttachmentButton( + icon: Icons.file_copy, + text: 'File', + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + ); + if (result != null) { + _logger.d('Files selected: ${result.files}'); + } + }, + ), + AttachmentButton( + icon: Icons.location_on, + text: 'Location', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Location not implemented'), + ), + ); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/bubble.dart b/lib/widgets/bubble.dart new file mode 100644 index 0000000..3792cc2 --- /dev/null +++ b/lib/widgets/bubble.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +const double kStateIconBottom = 4; +const double kStateIconRight = 6; +const double kStateIconSize = 18; + +final class Bubble extends StatelessWidget { + final String text; + final Color color; + final double bubbleRadius; + final BubbleDirection direction; + final BubbleState state; + final TextStyle? textStyle; + final EdgeInsets padding; + final double extraWidth; + + const Bubble({ + super.key, + required this.text, + required this.color, + this.bubbleRadius = 16, + this.direction = BubbleDirection.sent, + this.state = BubbleState.none, + this.textStyle, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + this.extraWidth = 48, + }); + + @override + Widget build(BuildContext context) { + final Positioned? stateIcon = switch (state) { + BubbleState.none => null, + BubbleState.sent => const Positioned( + bottom: kStateIconBottom, + right: kStateIconRight, + child: Icon( + Icons.done, + size: kStateIconSize, + color: Color(0xFF97AD8E), + ), + ), + BubbleState.delivered => const Positioned( + bottom: kStateIconBottom, + right: kStateIconRight, + child: Icon( + Icons.done_all, + size: kStateIconSize, + color: Color(0xFF97AD8E), + ), + ), + BubbleState.seen => const Positioned( + bottom: kStateIconBottom, + right: kStateIconRight, + child: Icon( + Icons.done_all, + size: kStateIconSize, + color: Color(0xFF92DEDA), + ), + ), + }; + + return Row( + mainAxisAlignment: switch (direction) { + BubbleDirection.sent => MainAxisAlignment.end, + BubbleDirection.received => MainAxisAlignment.start, + }, + children: [ + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - extraWidth, + ), + padding: padding, + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.all(Radius.circular(bubbleRadius)), + ), + child: Stack( + children: [ + Padding( + padding: stateIcon != null + ? EdgeInsets.fromLTRB(12, 6, 28, 6) + : EdgeInsets.symmetric(vertical: 6, horizontal: 12), + child: Text(text, style: textStyle), + ), + if (stateIcon != null) stateIcon, + ], + ), + ), + ), + ], + ); + } +} + +enum BubbleDirection { sent, received } + +enum BubbleState { none, sent, delivered, seen } diff --git a/lib/widgets/chat_bubble.dart b/lib/widgets/chat_bubble.dart new file mode 100644 index 0000000..a6c8dc6 --- /dev/null +++ b/lib/widgets/chat_bubble.dart @@ -0,0 +1,120 @@ +import 'dart:math'; + +import 'package:btox/db/database.dart'; +import 'package:btox/widgets/bubble.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// When the bubble is dragged past this fraction of the max bubble drag, the +/// reply icon will grow, indicating that the message will be replied to. +const _kBubbleDragActivation = 0.8; + +/// The maximum drag distance for the bubble (in either direction). +const _kMaxBubbleDrag = 48.0; + +Color _bubbleColor(bool isSender, ThemeData theme) { + return isSender ? Colors.blue : theme.splashColor; +} + +final class ChatBubble extends HookWidget { + final Message message; + final bool isSender; + final void Function()? onReply; + + const ChatBubble({ + super.key, + required this.message, + required this.isSender, + this.onReply, + }); + + @override + Widget build(BuildContext context) { + final bubbleDrag = useState(0.0); + final showTime = useState(false); + + final dragValue = + isSender ? max(0.0, -bubbleDrag.value) : max(0.0, bubbleDrag.value); + final replyIconExtraSize = + dragValue > _kMaxBubbleDrag * _kBubbleDragActivation ? 8.0 : 0.0; + + return Column( + children: [ + Stack( + children: [ + _align(Padding( + padding: EdgeInsets.all(8.0 - replyIconExtraSize / 2), + // Transparent to opaque depending on drag state. + child: Opacity( + opacity: dragValue / _kMaxBubbleDrag, + child: Icon( + Icons.reply, + size: 24 + replyIconExtraSize, + ), + ), + )), + GestureDetector( + onHorizontalDragUpdate: (details) { + final delta = details.primaryDelta; + if (delta == null) { + return; + } + bubbleDrag.value = clampDouble( + bubbleDrag.value + delta, + -_kMaxBubbleDrag, + _kMaxBubbleDrag, + ); + }, + onHorizontalDragEnd: (details) { + if (dragValue / _kMaxBubbleDrag > _kBubbleDragActivation) { + onReply?.call(); + } + bubbleDrag.value = 0; + }, + onTap: () => showTime.value = !showTime.value, + child: Padding( + padding: isSender + ? EdgeInsets.only(right: dragValue) + : EdgeInsets.only(left: dragValue), + child: Bubble( + text: message.content, + extraWidth: _kMaxBubbleDrag, + color: _bubbleColor(isSender, Theme.of(context)), + direction: isSender + ? BubbleDirection.sent + : BubbleDirection.received, + state: isSender ? BubbleState.seen : BubbleState.none, + textStyle: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ], + ), + if (showTime.value) + _align( + Text( + message.timestamp + .toLocal() + .toIso8601String() + .split('T') + .last + .split('.') + .first, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } + + Widget _align(Widget child) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: isSender ? Alignment.topRight : Alignment.topLeft, + child: child, + ), + ); + } +} diff --git a/lib/widgets/contact_list_item.dart b/lib/widgets/contact_list_item.dart index 46e5030..f8e6e46 100644 --- a/lib/widgets/contact_list_item.dart +++ b/lib/widgets/contact_list_item.dart @@ -1,7 +1,7 @@ import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/widgets/circle_identicon.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; final class ContactListItem extends StatelessWidget { final Contact contact; diff --git a/lib/widgets/friend_request_message_field.dart b/lib/widgets/friend_request_message_field.dart index 933bf09..1ecb232 100644 --- a/lib/widgets/friend_request_message_field.dart +++ b/lib/widgets/friend_request_message_field.dart @@ -1,8 +1,7 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - final class FriendRequestMessageField extends StatelessWidget { final ToxConstants constants; final TextEditingController controller; diff --git a/lib/widgets/main_menu.dart b/lib/widgets/main_menu.dart index 1f063f3..beeab5f 100644 --- a/lib/widgets/main_menu.dart +++ b/lib/widgets/main_menu.dart @@ -1,5 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/logger.dart'; import 'package:btox/pages/settings_page.dart'; import 'package:btox/pages/user_profile_page.dart'; @@ -7,7 +8,6 @@ import 'package:btox/widgets/connection_status_icon.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; const _logger = Logger(['MainMenu']); @@ -28,9 +28,7 @@ final class MainMenu extends StatelessWidget { return NavigationDrawer( children: [ DrawerHeader( - decoration: const BoxDecoration( - color: Colors.blue, - ), + decoration: const BoxDecoration(color: Colors.blue), child: ListTile( title: Text( profile.settings.nickname, diff --git a/lib/widgets/message_input.dart b/lib/widgets/message_input.dart new file mode 100644 index 0000000..9cba024 --- /dev/null +++ b/lib/widgets/message_input.dart @@ -0,0 +1,132 @@ +import 'package:btox/l10n/generated/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +final class MessageInput extends HookWidget { + final String hintText; + final String replyingTo; + final Color buttonColor; + final void Function() onAdd; + final void Function(String) onSend; + final void Function() onTapCloseReply; + + const MessageInput({ + super.key, + required this.hintText, + this.replyingTo = '', + this.buttonColor = Colors.blue, + required this.onAdd, + required this.onSend, + required this.onTapCloseReply, + }); + + @override + Widget build(BuildContext context) { + final messageInputController = useTextEditingController(); + final messageInputFocus = useFocusNode(); + final sendMode = useState(false); + + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (replyingTo.isNotEmpty) + Container( + color: Theme.of(context).primaryColor, + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Row( + children: [ + Icon(Icons.reply, color: buttonColor), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + AppLocalizations.of(context)!.re(replyingTo), + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + ), + ), + InkWell( + onTap: onTapCloseReply, + child: Icon( + Icons.close, + color: Theme.of(context).primaryColorLight, + ), + ), + ], + ), + ), + if (replyingTo.isNotEmpty) const Divider(height: 1), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Expanded( + child: TextField( + key: const Key('messageField'), + controller: messageInputController, + focusNode: messageInputFocus, + autofocus: true, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: 3, + onChanged: (value) => sendMode.value = value.isNotEmpty, + onSubmitted: (_) => _onSend( + messageInputController, + messageInputFocus, + ), + decoration: InputDecoration( + hintText: hintText, + hintMaxLines: 1, + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + fillColor: Theme.of(context).splashColor, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24.0), + borderSide: BorderSide.none, + ), + ), + ), + ), + IconButton( + color: buttonColor, + icon: sendMode.value + ? const Icon(Icons.send) + : const Icon(Icons.add_circle), + onPressed: () { + if (sendMode.value) { + _onSend( + messageInputController, + messageInputFocus, + ); + } else { + onAdd(); + } + }, + ), + ], + ), + ), + ], + ); + } + + void _onSend( + TextEditingController messageInputController, + FocusNode messageInputFocus, + ) { + final message = messageInputController.text; + if (message.isNotEmpty) { + onSend(message); + messageInputController.text = ''; + messageInputFocus.requestFocus(); + } + } +} diff --git a/lib/widgets/nickname_field.dart b/lib/widgets/nickname_field.dart index 3886120..a64b5ad 100644 --- a/lib/widgets/nickname_field.dart +++ b/lib/widgets/nickname_field.dart @@ -1,6 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; final class NicknameField extends StatelessWidget { final ToxConstants constants; diff --git a/lib/widgets/status_message_field.dart b/lib/widgets/status_message_field.dart index 9b271ed..4958a31 100644 --- a/lib/widgets/status_message_field.dart +++ b/lib/widgets/status_message_field.dart @@ -1,6 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; final class StatusMessageField extends StatelessWidget { final ToxConstants constants; diff --git a/lib/widgets/tox_id_field.dart b/lib/widgets/tox_id_field.dart index e0ef5a2..cc33950 100644 --- a/lib/widgets/tox_id_field.dart +++ b/lib/widgets/tox_id_field.dart @@ -1,8 +1,7 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - final class ToxIdField extends StatelessWidget { final ToxConstants constants; final TextEditingController controller; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 98a8393..78db22a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,13 @@ import FlutterMacOS import Foundation +import file_picker import path_provider_foundation import sodium_libs import sqlcipher_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9194626..2604115 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - file_picker (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - path_provider_foundation (0.0.1): - Flutter @@ -16,6 +18,7 @@ PODS: - SQLCipher (~> 4.5.7) DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - sodium_libs (from `Flutter/ephemeral/.symlinks/plugins/sodium_libs/darwin`) @@ -26,6 +29,8 @@ SPEC REPOS: - SQLCipher EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos FlutterMacOS: :path: Flutter/ephemeral path_provider_foundation: @@ -36,6 +41,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqlcipher_flutter_libs/macos SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 sodium_libs: d5a8c0ec38806fe1cff3caf98c8319378da0bc1d diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 08c3ab1..cff5a4b 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index ee95ab7..38da9a9 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/pubspec.lock b/pubspec.lock index 5d255af..72802f9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -206,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: "direct main" description: @@ -318,6 +326,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "6f6bfa8797f296965bdc3e1f702574ab49a540c19b9237b401e7c2b25dfe594c" + url: "https://pub.dev" + source: hosted + version: "9.0.0" fixnum: dependency: transitive description: @@ -360,6 +376,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + url: "https://pub.dev" + source: hosted + version: "2.0.24" flutter_riverpod: dependency: "direct main" description: @@ -1015,6 +1039,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + win32: + dependency: transitive + description: + name: win32 + sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef + url: "https://pub.dev" + source: hosted + version: "5.11.0" xdg_directories: dependency: transitive description: @@ -1048,5 +1080,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.7.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 14c0404..15ff04c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: cupertino_icons: ^1.0.2 drift: ^2.21.0 ffi: ^2.1.3 + file_picker: ^9.0.0 flutter_hooks: ^0.20.5 flutter_riverpod: ^2.6.1 freezed_annotation: ^2.4.4 diff --git a/test/btox_app_test.dart b/test/btox_app_test.dart index 2996465..b1807d0 100644 --- a/test/btox_app_test.dart +++ b/test/btox_app_test.dart @@ -21,7 +21,7 @@ import 'mocks/fake_toxcore.dart'; // Drift's timers being created during the test and then only torn down during // the tearDown after being reported as leaks at the test end. void main() { - final mySecretKey = SecretKey.fromString( + final mySecretKey = SecretKey.fromJson( String.fromCharCodes(Iterable.generate(64, (_) => 'F'.codeUnits.first))); final myToxId = ToxAddress.fromString( String.fromCharCodes(Iterable.generate(76, (_) => '0'.codeUnits.first))); diff --git a/test/chat_page_test.dart b/test/chat_page_test.dart new file mode 100644 index 0000000..29ade4b --- /dev/null +++ b/test/chat_page_test.dart @@ -0,0 +1,182 @@ +import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; +import 'package:btox/models/crypto.dart'; +import 'package:btox/models/id.dart'; +import 'package:btox/models/messaging.dart'; +import 'package:btox/models/persistence.dart'; +import 'package:btox/models/profile_settings.dart'; +import 'package:btox/pages/chat_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final profile = Profile( + id: Id(0), + active: true, + settings: ProfileSettings( + nickname: 'Yeetman', statusMessage: 'Yeeting everyone.'), + secretKey: SecretKey.fromJson( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + publicKey: PublicKey.fromJson( + '0000000000000000000000000000000000000000000000000000000000000000'), + nospam: ToxAddressNospam(0), + ); + + final contact = Contact( + id: Id(1), + profileId: profile.id, + name: 'Testman', + publicKey: PublicKey.fromJson( + '1111111111111111111111111111111111111111111111111111111111111111'), + ); + + testWidgets('Empty chat page should display message entry box', + (WidgetTester tester) async { + final messages = []; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: ChatPage( + profile: profile, + contact: Stream.value(contact), + messages: Stream.value(messages), + ), + ), + ); + + await tester.pumpAndSettle(); + + await expectLater(find.byType(ChatPage), + matchesGoldenFile('goldens/chat_page/empty.png')); + }); + + testWidgets('Chat page should display messages', (WidgetTester tester) async { + final messages = []; + + messages.add(_fakeInsertMessage( + Id(0), + newMessage( + contactId: contact.id, + parent: null, + merged: null, + author: contact.publicKey, + timestamp: DateTime(2025, 1, 1, 0, 1, 12), + content: 'Happy New Year!', + ), + )); + messages.add(_fakeInsertMessage( + Id(1), + newMessage( + contactId: contact.id, + parent: messages.last, + merged: null, + author: profile.publicKey, + timestamp: DateTime(2025, 1, 1, 0, 2, 23), + content: 'Thank you! Happy New Year to you too!', + ), + )); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: ChatPage( + profile: profile, + contact: Stream.value(contact), + messages: Stream.value(messages), + ), + ), + ); + + await tester.pumpAndSettle(); + + await expectLater(find.byType(ChatPage), + matchesGoldenFile('goldens/chat_page/messages.png')); + }); + + testWidgets('Long messages should be shown in a big bubble', + (WidgetTester tester) async { + final messages = []; + + messages.add(_fakeInsertMessage( + Id(0), + newMessage( + contactId: contact.id, + parent: null, + merged: null, + author: profile.publicKey, + timestamp: DateTime(2025, 1, 1, 0, 1, 12), + content: 'Here is a long message.\n' * 10, + ), + )); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: ChatPage( + profile: profile, + contact: Stream.value(contact), + messages: Stream.value(messages), + ), + ), + ); + + await tester.pumpAndSettle(); + + await expectLater(find.byType(ChatPage), + matchesGoldenFile('goldens/chat_page/messages_long.png')); + }); + + testWidgets('Tapping a message shows the time', (WidgetTester tester) async { + final messages = []; + + messages.add(_fakeInsertMessage( + Id(0), + newMessage( + contactId: contact.id, + parent: null, + merged: null, + author: contact.publicKey, + timestamp: DateTime(2025, 1, 1, 0, 1, 12), + content: 'Happy New Year!', + ), + )); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: ChatPage( + profile: profile, + contact: Stream.value(contact), + messages: Stream.value(messages), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Tap the message. + // TODO(iphydf): This is a hack. Find a better way to tap the message. + final bubble = find.byType(GestureDetector).first.evaluate().first.widget + as GestureDetector; + bubble.onTap!(); + + await tester.pumpAndSettle(); + + await expectLater(find.byType(ChatPage), + matchesGoldenFile('goldens/chat_page/messages_time.png')); + }); +} + +Message _fakeInsertMessage(Id id, MessagesCompanion message) { + return Message( + id: id, + contactId: message.contactId.value, + parent: message.parent.value, + merged: message.merged.value, + author: message.author.value, + sha: message.sha.value, + timestamp: message.timestamp.value, + content: message.content.value, + ); +} diff --git a/test/goldens/chat_empty.png b/test/goldens/chat_empty.png index 9812db3..afd9e39 100644 Binary files a/test/goldens/chat_empty.png and b/test/goldens/chat_empty.png differ diff --git a/test/goldens/chat_hello.png b/test/goldens/chat_hello.png index 87e15e8..234eca9 100644 Binary files a/test/goldens/chat_hello.png and b/test/goldens/chat_hello.png differ diff --git a/test/goldens/chat_page/empty.png b/test/goldens/chat_page/empty.png new file mode 100644 index 0000000..115808b Binary files /dev/null and b/test/goldens/chat_page/empty.png differ diff --git a/test/goldens/chat_page/messages.png b/test/goldens/chat_page/messages.png new file mode 100644 index 0000000..16dabc5 Binary files /dev/null and b/test/goldens/chat_page/messages.png differ diff --git a/test/goldens/chat_page/messages_long.png b/test/goldens/chat_page/messages_long.png new file mode 100644 index 0000000..02cadef Binary files /dev/null and b/test/goldens/chat_page/messages_long.png differ diff --git a/test/goldens/chat_page/messages_time.png b/test/goldens/chat_page/messages_time.png new file mode 100644 index 0000000..2278add Binary files /dev/null and b/test/goldens/chat_page/messages_time.png differ diff --git a/test/persistence_test.dart b/test/persistence_test.dart index 3950d5d..c285a5c 100644 --- a/test/persistence_test.dart +++ b/test/persistence_test.dart @@ -7,7 +7,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - final mySecretKey = SecretKey.fromString( + final mySecretKey = SecretKey.fromJson( String.fromCharCodes(Iterable.generate(64, (_) => 'F'.codeUnits.first))); final myToxId = ToxAddress.fromString( String.fromCharCodes(Iterable.generate(76, (_) => '0'.codeUnits.first))); @@ -44,7 +44,8 @@ void main() { final firstMsg = await db.getMessage(await db.addMessage(newMessage( contactId: contactId, parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: DateTime(2025, 1, 1, 0, 2, 10, 123), content: 'Happy new year!', ))); @@ -56,7 +57,8 @@ void main() { final secondMsg = await db.getMessage(await db.addMessage(newMessage( contactId: contactId, parent: firstMsg, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: DateTime(2026, 1, 1, 0, 2, 10, 123), content: 'Happy new year!', ))); @@ -95,7 +97,8 @@ void main() { final myFirstMsg = await db.getMessage(await db.addMessage(newMessage( contactId: contactId, parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, // 2 minutes after midnight. timestamp: DateTime(2025, 1, 1, 0, 2, 10, 123), content: 'Happy new year!', @@ -105,7 +108,8 @@ void main() { final friendFirstMsg = await db.getMessage(await db.addMessage(newMessage( contactId: contactId, parent: null, - origin: friendPk, + merged: null, + author: friendPk, // 1 minute before my message. timestamp: myFirstMsg.timestamp.subtract(const Duration(minutes: 1)), content: 'Happy new year!', @@ -115,7 +119,7 @@ void main() { contactId: contactId, parent: myFirstMsg, merged: friendFirstMsg, - origin: myToxId.publicKey, + author: myToxId.publicKey, // 1 minute after my message. timestamp: myFirstMsg.timestamp.add(const Duration(minutes: 1)), content: 'Haha, jinx!', @@ -129,7 +133,8 @@ void main() { final msg1 = newMessage( contactId: Id(1), parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: DateTime(2025, 1, 1, 0, 2, 10, 123, 456), content: 'Happy new year!', ); @@ -137,7 +142,8 @@ void main() { final msg2 = newMessage( contactId: Id(1), parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: msg1.timestamp.value.add(const Duration(microseconds: 100)), content: 'Happy new year!', ); @@ -149,7 +155,8 @@ void main() { final msg1 = newMessage( contactId: Id(1), parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: DateTime(2025, 1, 1, 0, 2, 10, 123), content: 'Happy new year!', ); @@ -157,7 +164,8 @@ void main() { final msg2 = newMessage( contactId: Id(1), parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: msg1.timestamp.value.add(const Duration(microseconds: 1000)), content: 'Happy new year!', );