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