Skip to content

Commit b3933e8

Browse files
committed
content: Implement GlobalTime
Fixes: #354
1 parent fb9e32b commit b3933e8

File tree

4 files changed

+116
-0
lines changed

4 files changed

+116
-0
lines changed

lib/model/content.dart

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

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

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

lib/widgets/content.dart

Lines changed: 40 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';
@@ -528,6 +530,9 @@ class _InlineContentBuilder {
528530
} else if (node is MathInlineNode) {
529531
return TextSpan(style: _kInlineMathStyle,
530532
children: [TextSpan(text: node.texSource)]);
533+
} else if (node is GlobalTimeNode) {
534+
return WidgetSpan(alignment: PlaceholderAlignment.middle,
535+
child: GlobalTime(node: node));
531536
} else if (node is UnimplementedInlineContentNode) {
532537
return _errorUnimplemented(node);
533538
} else {
@@ -687,6 +692,41 @@ class UserMention extends StatelessWidget {
687692
// borderRadius: BorderRadius.all(Radius.circular(3))));
688693
}
689694

695+
class GlobalTime extends StatelessWidget {
696+
const GlobalTime({super.key, required this.node});
697+
698+
final GlobalTimeNode node;
699+
700+
static final _backgroundColor = const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor();
701+
static final _borderColor = const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor();
702+
static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(intl): localize date
703+
704+
@override
705+
Widget build(BuildContext context) {
706+
// Design taken from css for `.rendered_markdown & time` in web,
707+
// see zulip:web/styles/rendered_markdown.css .
708+
final text = _dateFormat.format(node.datetime.toLocal());
709+
return Padding(
710+
padding: const EdgeInsets.symmetric(horizontal: 2),
711+
child: DecoratedBox(
712+
decoration: BoxDecoration(
713+
color: _backgroundColor,
714+
border: Border.all(width: 1, color: _borderColor),
715+
borderRadius: BorderRadius.circular(3)),
716+
child: Padding(
717+
padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
718+
child: Row(
719+
mainAxisSize: MainAxisSize.min,
720+
children: [
721+
const Icon(ZulipIcons.clock, size: kBaseFontSize),
722+
// Ad-hoc spacing adjustment per feedback:
723+
// https://chat.zulip.org/#narrow/stream/101-design/topic/clock.20icons/near/1729345
724+
const SizedBox(width: 1),
725+
Text(text, style: Paragraph.textStyle),
726+
]))));
727+
}
728+
}
729+
690730
class MessageImageEmoji extends StatelessWidget {
691731
const MessageImageEmoji({super.key, required this.node});
692732

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)