Skip to content

Commit e1bff3e

Browse files
sirpengignprice
authored andcommitted
content: Implement GlobalTime
Fixes: #354
1 parent 069c979 commit e1bff3e

File tree

4 files changed

+112
-0
lines changed

4 files changed

+112
-0
lines changed

lib/model/content.dart

Lines changed: 34 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
@@ -710,6 +731,19 @@ class _ZulipContentParser {
710731
return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode);
711732
}
712733

734+
if (localName == 'time' && className.isEmpty) {
735+
final dateTimeAttr = element.attributes['datetime'];
736+
if (dateTimeAttr == null) return unimplemented();
737+
738+
// This attribute is always in ISO 8601 format with a Z suffix;
739+
// see `Timestamp` in zulip:zerver/lib/markdown/__init__.py .
740+
final datetime = DateTime.tryParse(dateTimeAttr);
741+
if (datetime == null) return unimplemented();
742+
if (!datetime.isUtc) return unimplemented();
743+
744+
return GlobalTimeNode(datetime: datetime, debugHtmlNode: debugHtmlNode);
745+
}
746+
713747
if (localName == 'span' && className == 'katex') {
714748
final texSource = parseMath(element, block: false);
715749
if (texSource == null) return unimplemented();

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 {
@@ -718,6 +723,41 @@ class MessageImageEmoji extends StatelessWidget {
718723
}
719724
}
720725

726+
class GlobalTime extends StatelessWidget {
727+
const GlobalTime({super.key, required this.node});
728+
729+
final GlobalTimeNode node;
730+
731+
static final _backgroundColor = const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor();
732+
static final _borderColor = const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor();
733+
static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(intl): localize date
734+
735+
@override
736+
Widget build(BuildContext context) {
737+
// Design taken from css for `.rendered_markdown & time` in web,
738+
// see zulip:web/styles/rendered_markdown.css .
739+
final text = _dateFormat.format(node.datetime.toLocal());
740+
return Padding(
741+
padding: const EdgeInsets.symmetric(horizontal: 2),
742+
child: DecoratedBox(
743+
decoration: BoxDecoration(
744+
color: _backgroundColor,
745+
border: Border.all(width: 1, color: _borderColor),
746+
borderRadius: BorderRadius.circular(3)),
747+
child: Padding(
748+
padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
749+
child: Row(
750+
mainAxisSize: MainAxisSize.min,
751+
children: [
752+
const Icon(ZulipIcons.clock, size: kBaseFontSize),
753+
// Ad-hoc spacing adjustment per feedback:
754+
// https://chat.zulip.org/#narrow/stream/101-design/topic/clock.20icons/near/1729345
755+
const SizedBox(width: 1),
756+
Text(text, style: Paragraph.textStyle),
757+
]))));
758+
}
759+
}
760+
721761
void _launchUrl(BuildContext context, String urlString) async {
722762
Future<void> showError(BuildContext context, String? message) {
723763
return showErrorDialog(context: context,

test/model/content_test.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,32 @@ void main() {
201201
'<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>',
202202
const MathInlineNode(texSource: r'\lambda'));
203203

204+
group('global times', () {
205+
testParseInline('smoke',
206+
// "<time:2024-01-30T17:33:00Z>"
207+
'<p><time datetime="2024-01-30T17:33:00Z">2024-01-30T17:33:00Z</time></p>',
208+
GlobalTimeNode(datetime: DateTime.parse('2024-01-30T17:33Z')),
209+
);
210+
211+
testParseInline('handles missing attribute',
212+
// No markdown, this is unexpected response
213+
'<p><time>2024-01-30T17:33:00Z</time></p>',
214+
inlineUnimplemented('<time>2024-01-30T17:33:00Z</time>'),
215+
);
216+
217+
testParseInline('handles DateTime.parse failure',
218+
// No markdown, this is unexpected response
219+
'<p><time datetime="2024">2024-01-30T17:33:00Z</time></p>',
220+
inlineUnimplemented('<time datetime="2024">2024-01-30T17:33:00Z</time>'),
221+
);
222+
223+
testParseInline('handles unexpected timezone',
224+
// No markdown, this is unexpected response
225+
'<p><time datetime="2024-01-30T17:33:00">2024-01-30T17:33:00</time></p>',
226+
inlineUnimplemented('<time datetime="2024-01-30T17:33:00">2024-01-30T17:33:00</time>'),
227+
);
228+
});
229+
204230
//
205231
// Block content.
206232
//

test/widgets/content_test.dart

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

285+
testWidgets('GlobalTime smoke', (tester) async {
286+
// "<time:2024-01-30T17:33:00Z>"
287+
await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(
288+
'<p><time datetime="2024-01-30T17:33:00Z">2024-01-30T17:33:00Z</time></p>'
289+
).nodes)));
290+
// The time is shown in the user's timezone and the result will depend on
291+
// the timezone of the environment running this test. Accept here a wide
292+
// range of times. See comments in "show dates" test in
293+
// `test/widgets/message_list_test.dart`.
294+
tester.widget(find.textContaining(RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$')));
295+
});
296+
285297
group('RealmContentNetworkImage', () {
286298
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);
287299

0 commit comments

Comments
 (0)