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;
}