diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 04e2b86e9c..2fd7a58885 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/clock.svg b/assets/icons/clock.svg
new file mode 100644
index 0000000000..59b892e052
--- /dev/null
+++ b/assets/icons/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/lib/model/content.dart b/lib/model/content.dart
index 0b0bb502bb..925a3e64f3 100644
--- a/lib/model/content.dart
+++ b/lib/model/content.dart
@@ -533,6 +533,27 @@ class MathInlineNode extends InlineContentNode {
}
}
+class GlobalTimeNode extends InlineContentNode {
+ const GlobalTimeNode({super.debugHtmlNode, required this.datetime});
+
+ /// Always in UTC, enforced in [_ZulipContentParser.parseInlineContent].
+ final DateTime datetime;
+
+ @override
+ bool operator ==(Object other) {
+ return other is GlobalTimeNode && other.datetime == datetime;
+ }
+
+ @override
+ int get hashCode => Object.hash('GlobalTimeNode', datetime);
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty('datetime', datetime));
+ }
+}
+
////////////////////////////////////////////////////////////////
// Ported from https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112
@@ -710,6 +731,19 @@ class _ZulipContentParser {
return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode);
}
+ if (localName == 'time' && className.isEmpty) {
+ final dateTimeAttr = element.attributes['datetime'];
+ if (dateTimeAttr == null) return unimplemented();
+
+ // This attribute is always in ISO 8601 format with a Z suffix;
+ // see `Timestamp` in zulip:zerver/lib/markdown/__init__.py .
+ final datetime = DateTime.tryParse(dateTimeAttr);
+ if (datetime == null) return unimplemented();
+ if (!datetime.isUtc) return unimplemented();
+
+ return GlobalTimeNode(datetime: datetime, debugHtmlNode: debugHtmlNode);
+ }
+
if (localName == 'span' && className == 'katex') {
final texSource = parseMath(element, block: false);
if (texSource == null) return unimplemented();
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index dd5cbdc156..365153d25b 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:html/dom.dart' as dom;
+import 'package:intl/intl.dart';
import '../api/core.dart';
import '../api/model/model.dart';
@@ -11,6 +12,7 @@ import '../model/internal_link.dart';
import '../model/store.dart';
import 'code_block.dart';
import 'dialog.dart';
+import 'icons.dart';
import 'lightbox.dart';
import 'message_list.dart';
import 'store.dart';
@@ -528,6 +530,9 @@ class _InlineContentBuilder {
} else if (node is MathInlineNode) {
return TextSpan(style: _kInlineMathStyle,
children: [TextSpan(text: node.texSource)]);
+ } else if (node is GlobalTimeNode) {
+ return WidgetSpan(alignment: PlaceholderAlignment.middle,
+ child: GlobalTime(node: node));
} else if (node is UnimplementedInlineContentNode) {
return _errorUnimplemented(node);
} else {
@@ -718,6 +723,41 @@ class MessageImageEmoji extends StatelessWidget {
}
}
+class GlobalTime extends StatelessWidget {
+ const GlobalTime({super.key, required this.node});
+
+ final GlobalTimeNode node;
+
+ static final _backgroundColor = const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor();
+ static final _borderColor = const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor();
+ static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(intl): localize date
+
+ @override
+ Widget build(BuildContext context) {
+ // Design taken from css for `.rendered_markdown & time` in web,
+ // see zulip:web/styles/rendered_markdown.css .
+ final text = _dateFormat.format(node.datetime.toLocal());
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 2),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: _backgroundColor,
+ border: Border.all(width: 1, color: _borderColor),
+ borderRadius: BorderRadius.circular(3)),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(ZulipIcons.clock, size: kBaseFontSize),
+ // Ad-hoc spacing adjustment per feedback:
+ // https://chat.zulip.org/#narrow/stream/101-design/topic/clock.20icons/near/1729345
+ const SizedBox(width: 1),
+ Text(text, style: Paragraph.textStyle),
+ ]))));
+ }
+}
+
void _launchUrl(BuildContext context, String urlString) async {
Future showError(BuildContext context, String? message) {
return showErrorDialog(context: context,
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index 461be1f107..458fa080f4 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -36,38 +36,41 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "chevron_right".
static const IconData chevron_right = IconData(0xf104, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "clock".
+ static const IconData clock = IconData(0xf105, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "globe".
- static const IconData globe = IconData(0xf105, fontFamily: "Zulip Icons");
+ static const IconData globe = IconData(0xf106, fontFamily: "Zulip Icons");
/// The Zulip custom icon "group_dm".
- static const IconData group_dm = IconData(0xf106, fontFamily: "Zulip Icons");
+ static const IconData group_dm = IconData(0xf107, fontFamily: "Zulip Icons");
/// The Zulip custom icon "hash_sign".
- static const IconData hash_sign = IconData(0xf107, fontFamily: "Zulip Icons");
+ static const IconData hash_sign = IconData(0xf108, fontFamily: "Zulip Icons");
/// The Zulip custom icon "language".
- static const IconData language = IconData(0xf108, fontFamily: "Zulip Icons");
+ static const IconData language = IconData(0xf109, fontFamily: "Zulip Icons");
/// The Zulip custom icon "lock".
- static const IconData lock = IconData(0xf109, fontFamily: "Zulip Icons");
+ static const IconData lock = IconData(0xf10a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "mute".
- static const IconData mute = IconData(0xf10a, fontFamily: "Zulip Icons");
+ static const IconData mute = IconData(0xf10b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "read_receipts".
- static const IconData read_receipts = IconData(0xf10b, fontFamily: "Zulip Icons");
+ static const IconData read_receipts = IconData(0xf10c, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star_filled".
- static const IconData star_filled = IconData(0xf10c, fontFamily: "Zulip Icons");
+ static const IconData star_filled = IconData(0xf10d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf10d, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf10e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf10e, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf10f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "user".
- static const IconData user = IconData(0xf10f, fontFamily: "Zulip Icons");
+ static const IconData user = IconData(0xf110, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index dc97c89b92..bdfe8dbfda 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -201,6 +201,32 @@ void main() {
'λ
',
const MathInlineNode(texSource: r'\lambda'));
+ group('global times', () {
+ testParseInline('smoke',
+ // ""
+ '',
+ GlobalTimeNode(datetime: DateTime.parse('2024-01-30T17:33Z')),
+ );
+
+ testParseInline('handles missing attribute',
+ // No markdown, this is unexpected response
+ '',
+ inlineUnimplemented(''),
+ );
+
+ testParseInline('handles DateTime.parse failure',
+ // No markdown, this is unexpected response
+ '',
+ inlineUnimplemented(''),
+ );
+
+ testParseInline('handles unexpected timezone',
+ // No markdown, this is unexpected response
+ '',
+ inlineUnimplemented(''),
+ );
+ });
+
//
// Block content.
//
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index deb30b43e1..b60e4789c8 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -282,6 +282,18 @@ void main() {
tester.widget(find.text(r'\lambda'));
});
+ testWidgets('GlobalTime smoke', (tester) async {
+ // ""
+ await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(
+ ''
+ ).nodes)));
+ // The time is shown in the user's timezone and the result will depend on
+ // the timezone of the environment running this test. Accept here a wide
+ // range of times. See comments in "show dates" test in
+ // `test/widgets/message_list_test.dart`.
+ tester.widget(find.textContaining(RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$')));
+ });
+
group('RealmContentNetworkImage', () {
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);