Skip to content

Commit 4a424dd

Browse files
committed
content: Implement GlobalTime
Fixes: zulip#354
1 parent 6e29734 commit 4a424dd

File tree

4 files changed

+114
-0
lines changed

4 files changed

+114
-0
lines changed

lib/model/content.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,28 @@ class MathInlineNode extends InlineContentNode {
533533
}
534534
}
535535

536+
class GlobalTimeNode extends InlineContentNode {
537+
const GlobalTimeNode({super.debugHtmlNode, required this.datetime});
538+
539+
// `datetime` is always in UTC, this is enforced
540+
// in [parseInlineContent].
541+
final DateTime datetime;
542+
543+
@override
544+
bool operator ==(Object other) {
545+
return other is GlobalTimeNode && other.datetime == datetime;
546+
}
547+
548+
@override
549+
int get hashCode => Object.hash('GlobalTimeNode', datetime);
550+
551+
@override
552+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
553+
super.debugFillProperties(properties);
554+
properties.add(DiagnosticsProperty<DateTime>('datetime', datetime));
555+
}
556+
}
557+
536558
////////////////////////////////////////////////////////////////
537559
538560
// Ported from https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112
@@ -717,6 +739,23 @@ class _ZulipContentParser {
717739
return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode);
718740
}
719741

742+
if (localName == 'time' && classes.isEmpty) {
743+
final attr = element.attributes['datetime'];
744+
if (attr == null) return unimplemented();
745+
746+
// This attribute is always in ISO 8601 format with a Z suffix;
747+
// see `Timestamp` in zulip:zerver/lib/markdown/__init__.py .
748+
final DateTime datetime;
749+
try {
750+
datetime = DateTime.parse(attr);
751+
} on FormatException {
752+
return unimplemented();
753+
}
754+
if (!datetime.isUtc) return unimplemented();
755+
756+
return GlobalTimeNode(datetime: datetime, debugHtmlNode: debugHtmlNode);
757+
}
758+
720759
if (localName == 'span'
721760
&& classes.length == 1
722761
&& classes.contains('katex')) {

lib/widgets/content.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
22
import 'package:flutter/material.dart';
33
import 'package:flutter/services.dart';
44
import 'package:html/dom.dart' as dom;
5+
import 'package:intl/intl.dart';
56

67
import '../api/core.dart';
78
import '../api/model/model.dart';
@@ -11,6 +12,7 @@ import '../model/internal_link.dart';
1112
import '../model/store.dart';
1213
import 'code_block.dart';
1314
import 'dialog.dart';
15+
import 'icons.dart';
1416
import 'lightbox.dart';
1517
import 'message_list.dart';
1618
import 'store.dart';
@@ -523,6 +525,9 @@ class _InlineContentBuilder {
523525
} else if (node is MathInlineNode) {
524526
return TextSpan(style: _kInlineMathStyle,
525527
children: [TextSpan(text: node.texSource)]);
528+
} else if (node is GlobalTimeNode) {
529+
return WidgetSpan(alignment: PlaceholderAlignment.middle,
530+
child: GlobalTime(node: node));
526531
} else if (node is UnimplementedInlineContentNode) {
527532
return _errorUnimplemented(node);
528533
} else {
@@ -681,6 +686,38 @@ class UserMention extends StatelessWidget {
681686
// borderRadius: BorderRadius.all(Radius.circular(3))));
682687
}
683688

689+
class GlobalTime extends StatelessWidget {
690+
const GlobalTime({super.key, required this.node});
691+
692+
final GlobalTimeNode node;
693+
694+
static final _backgroundColor = const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor();
695+
static final _borderColor = const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor();
696+
static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(intl): localize date
697+
698+
@override
699+
Widget build(BuildContext context) {
700+
// Design taken from css for `.rendered_markdown & time` in web,
701+
// see zulip:web/styles/rendered_markdown.css .
702+
final text = _dateFormat.format(node.datetime.toLocal());
703+
return Padding(
704+
padding: const EdgeInsets.symmetric(horizontal: 2),
705+
child: DecoratedBox(
706+
decoration: BoxDecoration(
707+
color: _backgroundColor,
708+
border: Border.all(width: 1, color: _borderColor),
709+
borderRadius: BorderRadius.circular(3)),
710+
child: Padding(
711+
padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
712+
child: Row(
713+
mainAxisSize: MainAxisSize.min,
714+
children: [
715+
const Icon(ZulipIcons.clock, size: kBaseFontSize),
716+
Text(text, style: Paragraph.getTextStyle(context)),
717+
]))));
718+
}
719+
}
720+
684721
class MessageImageEmoji extends StatelessWidget {
685722
const MessageImageEmoji({super.key, required this.node});
686723

test/model/content_test.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,32 @@ void main() {
176176
'<span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">λ</span></span></span></span></p>',
177177
const MathInlineNode(texSource: r'\lambda'));
178178

179+
group('global times', () {
180+
testParseInline('smoke',
181+
// "<time:2024-01-30T17:33:00Z">"
182+
'<p><time datetime="2024-01-30T17:33:00Z">2024-01-30T17:33:00Z</time></p>',
183+
GlobalTimeNode(datetime: DateTime.parse('2024-01-30T17:33Z')),
184+
);
185+
186+
testParseInline('handles missing attribute',
187+
// No markdown, this is unexpected response
188+
'<p><time>2024-01-30T17:33:00Z</time></p>',
189+
inlineUnimplemented('<time>2024-01-30T17:33:00Z</time>'),
190+
);
191+
192+
testParseInline('handles DateTime.parse failure',
193+
// No markdown, this is unexpected response
194+
'<p><time datetime="2024">2024-01-30T17:33:00Z</time></p>',
195+
inlineUnimplemented('<time datetime="2024">2024-01-30T17:33:00Z</time>'),
196+
);
197+
198+
testParseInline('handles unexpected timezone',
199+
// No markdown, this is unexpected response
200+
'<p><time datetime="2024-01-30T17:33:00">2024-01-30T17:33:00</time></p>',
201+
inlineUnimplemented('<time datetime="2024-01-30T17:33:00">2024-01-30T17:33:00</time>'),
202+
);
203+
});
204+
179205
//
180206
// Block content.
181207
//

test/widgets/content_test.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ void main() {
9393
tester.widget(find.text(r'\lambda'));
9494
});
9595

96+
testWidgets('GlobalTime smoke', (tester) async {
97+
// "<time:2024-01-30T17:33:00Z">"
98+
await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(
99+
'<p><time datetime="2024-01-30T17:33:00Z">2024-01-30T17:33:00Z</time></p>'
100+
).nodes)));
101+
// The time is shown in the user's timezone and the result will depend on
102+
// the timezone of the environment running this test. Accept here a wide
103+
// range of times. See comments in "show dates" test in
104+
// `test/widgets/message_list_test.dart`.
105+
tester.widget(find.textContaining(RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$')));
106+
});
107+
96108
Future<void> tapText(WidgetTester tester, Finder textFinder) async {
97109
final height = tester.getSize(textFinder).height;
98110
final target = tester.getTopLeft(textFinder)

0 commit comments

Comments
 (0)