diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 1fd2db9d1d..cbb2ea4e94 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/message_edited_icon.svg b/assets/icons/message_edited_icon.svg new file mode 100644 index 0000000000..86964d0cf2 --- /dev/null +++ b/assets/icons/message_edited_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/message_moved_icon.svg b/assets/icons/message_moved_icon.svg new file mode 100644 index 0000000000..22298088b9 --- /dev/null +++ b/assets/icons/message_moved_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 25b0e1debb..e8bebdd545 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -569,7 +569,7 @@ sealed class Message { String content; final String contentType; - // final List editHistory; // TODO handle + final List? editHistory; final int id; bool isMeMessage; int? lastEditTimestamp; @@ -612,6 +612,7 @@ sealed class Message { required this.client, required this.content, required this.contentType, + required this.editHistory, required this.id, required this.isMeMessage, required this.lastEditTimestamp, @@ -677,6 +678,7 @@ class StreamMessage extends Message { required super.client, required super.content, required super.contentType, + required super.editHistory, required super.id, required super.isMeMessage, required super.lastEditTimestamp, @@ -779,6 +781,7 @@ class DmMessage extends Message { required super.client, required super.content, required super.contentType, + required super.editHistory, required super.id, required super.isMeMessage, required super.lastEditTimestamp, @@ -802,3 +805,40 @@ class DmMessage extends Message { @override Map toJson() => _$DmMessageToJson(this); } + + +@JsonSerializable(fieldRename: FieldRename.snake) +class MessageEditHistory { + final String? prevContent; + final String? prevRenderedContent; + final int? prevRenderedContentVersion; + final int? prevStream; + final int? stream; + final int timestamp; + final String? topic; + final int? userId; + + @JsonKey(readValue: _readPrevTopic) + String? prevTopic; + + static String? _readPrevTopic(Map json, String key) { + return json[key] ?? json['prev_subject']; + } + + MessageEditHistory( { + required this.prevContent, + required this.prevRenderedContent, + required this.prevRenderedContentVersion, + required this.prevStream, + required this.prevTopic, + required this.stream, + required this.timestamp, + required this.topic, + required this.userId, + }); + + factory MessageEditHistory.fromJson(Map json) => + _$MessageEditHistoryFromJson(json); + + Map toJson() =>_$MessageEditHistoryToJson(this); +} diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index fd1e355d69..657791cf82 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -258,11 +258,40 @@ Map _$SubscriptionToJson(Subscription instance) => 'color': instance.color, }; +MessageEditHistory _$MessageEditHistoryFromJson(Map json) => + MessageEditHistory( + prevContent: json['prev_content'] as String?, + prevRenderedContent: json['prev_rendered_content'] as String?, + prevRenderedContentVersion: json['prev_rendered_content_version'] as int?, + prevStream: json['prev_stream'] as int?, + prevTopic: json['prev_topic'] as String?, + stream: json['stream'] as int?, + timestamp: json['timestamp'] as int, + topic: json['topic'] as String?, + userId: json['user_id'] as int?, + ); + +Map _$MessageEditHistoryToJson(MessageEditHistory instance) => + { + 'prev_content': instance.prevContent, + 'prev_rendered_content': instance.prevRenderedContent, + 'prev_rendered_content_version': instance.prevRenderedContentVersion, + 'prev_stream': instance.prevStream, + 'prev_topic': instance.prevTopic, + 'stream': instance.stream, + 'timestamp': instance.timestamp, + 'topic': instance.topic, + 'user_id': instance.userId, + }; + StreamMessage _$StreamMessageFromJson(Map json) => StreamMessage( client: json['client'] as String, content: json['content'] as String, contentType: json['content_type'] as String, + editHistory: (json['edit_history'] as List?) + ?.map((e) => MessageEditHistory.fromJson(e as Map)) + .toList(), id: json['id'] as int, isMeMessage: json['is_me_message'] as bool, lastEditTimestamp: json['last_edit_timestamp'] as int?, @@ -286,6 +315,7 @@ Map _$StreamMessageToJson(StreamMessage instance) => 'client': instance.client, 'content': instance.content, 'content_type': instance.contentType, + 'edit_history': instance.editHistory, 'id': instance.id, 'is_me_message': instance.isMeMessage, 'last_edit_timestamp': instance.lastEditTimestamp, @@ -322,6 +352,9 @@ DmMessage _$DmMessageFromJson(Map json) => DmMessage( client: json['client'] as String, content: json['content'] as String, contentType: json['content_type'] as String, + editHistory: (json['edit_history'] as List?) + ?.map((e) => MessageEditHistory.fromJson(e as Map)) + .toList(), id: json['id'] as int, isMeMessage: json['is_me_message'] as bool, lastEditTimestamp: json['last_edit_timestamp'] as int?, @@ -344,6 +377,7 @@ Map _$DmMessageToJson(DmMessage instance) => { 'client': instance.client, 'content': instance.content, 'content_type': instance.contentType, + 'edit_history': instance.editHistory, 'id': instance.id, 'is_me_message': instance.isMeMessage, 'last_edit_timestamp': instance.lastEditTimestamp, diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 23080a2257..a747e2e9a1 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -57,23 +57,30 @@ abstract final class ZulipIcons { /// The Zulip custom icon "lock". static const IconData lock = IconData(0xf10b, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "message_edited_icon". + static const IconData message_edited_icon = IconData(0xf10c, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "message_moved_icon". + static const IconData message_moved_icon = IconData(0xf10d, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf113, fontFamily: "Zulip Icons"); + // END GENERATED ICON DATA } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index d80f31bea4..52498de339 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -23,6 +23,7 @@ import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; + class MessageListPage extends StatefulWidget { const MessageListPage({super.key, required this.narrow}); @@ -892,6 +893,16 @@ class MessageWithPossibleSender extends StatelessWidget { @override Widget build(BuildContext context) { final message = item.message; + final history = message.editHistory; + bool messageEdited = false, messageMoved = false; + + if(message.editHistory != null) { + if((history?.last.topic != history?.last.prevTopic) || + (history?.last.stream != history?.last.prevStream)){ + messageMoved = true;} + if(history?.last.prevContent!=null){ + messageEdited = true; + }} Widget? senderRow; if (item.showSender) { @@ -939,7 +950,8 @@ class MessageWithPossibleSender extends StatelessWidget { onLongPress: () => showMessageActionSheet(context: context, message: message), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Column(children: [ + child: Column( + children: [ if (senderRow != null) Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), child: senderRow), @@ -950,6 +962,7 @@ class MessageWithPossibleSender extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ MessageContent(message: message, content: item.content), + if(messageMoved || messageEdited) SlidableMarker(messageMoved: messageMoved, messageEdited: messageEdited), if ((message.reactions?.total ?? 0) > 0) ReactionChipsList(messageId: message.id, reactions: message.reactions!) ])), @@ -966,6 +979,70 @@ class MessageWithPossibleSender extends StatelessWidget { } } +class SlidableMarker extends StatefulWidget { + final bool messageMoved; + final bool messageEdited; + + const SlidableMarker({ + super.key, + required this.messageMoved, + required this.messageEdited, + }); + + @override + State createState() => _SlidableMarkerState(); +} + +class _SlidableMarkerState extends State { + double _dragPosition = 17; + + + @override + Widget build(BuildContext context) { + double containerWidth = 17; + + if (_dragPosition >= 17) { + if (_dragPosition > 60) { + containerWidth = 60; + } else { + containerWidth = _dragPosition; + } + } + else{ + _dragPosition = 17; + } + + return GestureDetector( + onHorizontalDragUpdate: (details) { + setState(() { + _dragPosition += details.delta.dx; + });}, + child: SizedBox( + height: 20, + child: Stack( + children: [ + Container( + width: containerWidth, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(2), + color : const Color(0xFFDDECF6)), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 1), + child: Text( + widget.messageEdited ? 'Edited' : 'Moved', + style: const TextStyle(fontSize: 15, overflow: TextOverflow.clip, color: Color(0xFF26516E)), + ))), + widget.messageMoved ? const Padding(padding: EdgeInsets.all(1), + child :Icon(ZulipIcons.message_moved_icon, size: 14, + color: Color(0xFF26516E))) : + const Padding(padding: EdgeInsets.all(1), + child :Icon(ZulipIcons.message_edited_icon, size: 14, + color: Color(0xFF26516E))) + ]))])));} + } + // TODO web seems to ignore locale in formatting time, but we could do better final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); diff --git a/pubspec.lock b/pubspec.lock index 867978a671..eced8878dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1283,4 +1283,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0-140.0.dev <4.0.0" - flutter: ">=3.20.0-7.0.pre.63" + flutter: ">=3.20.0-7.0.pre.63" \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 2c506dcfc1..22b7f595d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -138,4 +138,4 @@ flutter: fonts: - asset: assets/icons/ZulipIcons.ttf - # If adding a font, remember to account for its license in lib/licenses.dart. + # If adding a font, remember to account for its license in lib/licenses.dart. \ No newline at end of file diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index ca854743ff..51d39cd976 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -397,4 +397,54 @@ void main() { .deepEquals([2, 3, 11]); }); }); + +group('MessageEditHistory', () { + test('test MessageEditHistory.fromJson', () { + + final Map baseJson = Map.unmodifiable(deepToJson( + eg.editHistory(timestamp: 12369, userId: 123), + ) as Map); + + MessageEditHistory parse(Map specialJson) { + return MessageEditHistory.fromJson({ ...baseJson, ...specialJson }); + } + + final history = parse( + {'prev_content': 'Lorem Ipsum', 'timestamp': 12369, 'user_id': 123, + 'prev_rendered_content': '

Lorem Ipsum

'}); + check(history.prevContent).equals('Lorem Ipsum'); + check(history.prevRenderedContent).equals('

Lorem Ipsum

'); + check(history.userId).equals(123); + check(history.timestamp).equals(12369); + }); + + test('streamMessage with editHistory list', () { + User user = eg.user(); + + var edit1 = eg.editHistory( + timestamp: DateTime.now().millisecondsSinceEpoch, + userId: user.userId, + ); + + var edit2 = eg.editHistory( + prevContent: 'Previous content', + prevRenderedContent: 'Previous rendered content', + timestamp: DateTime.now().millisecondsSinceEpoch, + userId: user.userId, + ); + + var streamMsg = eg.streamMessage( + sender: user, + topic: 'Test topic', + content: 'Test content', + timestamp: DateTime.now().millisecondsSinceEpoch, + editHistory: [edit2, edit1], + ); + + check(streamMsg.editHistory![0].timestamp).equals(edit2.timestamp); + check(streamMsg.editHistory![0].userId).equals(edit2.userId); + check(streamMsg.editHistory![1].prevContent).equals(edit1.prevContent); + check(streamMsg.editHistory![1].prevRenderedContent).equals(edit1.prevRenderedContent); + }); +}); } diff --git a/test/example_data.dart b/test/example_data.dart index be976bce84..31c04ed209 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -276,6 +276,7 @@ StreamMessage streamMessage({ List? reactions, int? timestamp, List? flags, + List? editHistory, }) { final effectiveStream = stream ?? _stream(); // The use of JSON here is convenient in order to delegate parts of the data @@ -296,6 +297,37 @@ StreamMessage streamMessage({ 'subject': topic ?? 'example topic', 'timestamp': timestamp ?? 1678139636, 'type': 'stream', + 'edit_history': editHistory, + }) as Map); +} + +/// Creates a [MessageEditHistory] object with the provided parameters. +/// +/// The [timestamp] parameter is required and represents the timestamp of the message edit. +/// The [userId] parameter is required but nullable and represents the user ID of the message editor. +/// +/// Returns a [MessageEditHistory] object. +MessageEditHistory editHistory({ + String? prevContent, + String? prevRenderedContent, + int? prevRenderedContentVersion, + int? prevStream, + String? prevTopic, + int? stream, + required int timestamp, + String? topic, + required int? userId, +}) { + return MessageEditHistory.fromJson(deepToJson({ + 'prev_content': prevContent ?? 'Base previous content', + 'prev_rendered_content': prevRenderedContent ?? 'Base previous rendered content', + 'prev_rendered_content_version': prevRenderedContentVersion ?? 1, + 'prev_stream': prevStream ?? 0, + 'prev_topic': prevTopic ?? 'Base previous topic', + 'stream': stream ?? 0, + 'timestamp': timestamp, + 'topic': topic ?? 'Base topic', + 'user_id': userId, }) as Map); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index b8ee2e0e84..3ce7330de1 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -911,4 +911,51 @@ void main() { }); }); }); -} + + group('Slidable Marker Tests', (){ + testWidgets('displays correct text when message is moved', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: SlidableMarker( + messageMoved: true, + messageEdited: false, + )))); + check(find.text('Moved').evaluate()).isNotEmpty(); + check(find.text('Edited').evaluate()).isEmpty(); + }); + + testWidgets('displays correct text when message is edited', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: SlidableMarker( + messageMoved: false, + messageEdited: true, + )))); + check(find.text('Edited').evaluate()).isNotEmpty(); + check(find.text('Moved').evaluate()).isEmpty(); + }); + + testWidgets('Marker not go out of bounds', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: SlidableMarker(messageEdited: true, messageMoved: true), + ), + )); + + Finder containerFinder = find.byType(Container); + + // Check for initial width + check(tester.getSize(containerFinder).width).equals(17); + + // Check for maximum width + await tester.drag(containerFinder, const Offset(50, 0)); + await tester.pump(); + check(tester.getSize(containerFinder).width).equals(60); + + // Check for minimum width + await tester.drag(containerFinder, const Offset(-80, 0)); + await tester.pump(); + check(tester.getSize(containerFinder).width).equals(17); + }); + }); +} \ No newline at end of file diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index 930d2071a3..903f4899d6 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index b25e363efa..955ee3038f 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -31,6 +31,11 @@ bool FlutterWindow::OnCreate() { this->Show(); }); + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + return true; }