Skip to content

Commit 0be474a

Browse files
chrisbobbegnprice
authored andcommitted
content: Handle spoilers
Fixes: #358
1 parent 4663118 commit 0be474a

File tree

6 files changed

+307
-0
lines changed

6 files changed

+307
-0
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@
366366
"@serverUrlValidationErrorUnsupportedScheme": {
367367
"description": "Error message when URL has an unsupported scheme."
368368
},
369+
"spoilerDefaultHeaderText": "Spoiler",
370+
"@spoilerDefaultHeaderText": {
371+
"description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )."
372+
},
369373
"markAllAsReadLabel": "Mark all messages as read",
370374
"@markAllAsReadLabel": {
371375
"description": "Button text to mark messages as read."

lib/model/content.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,21 @@ class QuotationNode extends BlockContentNode {
258258
}
259259
}
260260

261+
class SpoilerNode extends BlockContentNode {
262+
const SpoilerNode({super.debugHtmlNode, required this.header, required this.content});
263+
264+
final List<BlockContentNode> header;
265+
final List<BlockContentNode> content;
266+
267+
@override
268+
List<DiagnosticsNode> debugDescribeChildren() {
269+
return [
270+
_BlockContentListNode(header).toDiagnosticsNode(name: 'header'),
271+
_BlockContentListNode(content).toDiagnosticsNode(name: 'content'),
272+
];
273+
}
274+
}
275+
261276
class CodeBlockNode extends BlockContentNode {
262277
const CodeBlockNode(this.spans, {super.debugHtmlNode});
263278

@@ -809,6 +824,26 @@ class _ZulipContentParser {
809824
return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode);
810825
}
811826

827+
BlockContentNode parseSpoilerNode(dom.Element divElement) {
828+
assert(_debugParserContext == _ParserContext.block);
829+
assert(divElement.localName == 'div'
830+
&& divElement.className == 'spoiler-block');
831+
832+
if (divElement.nodes case [
833+
dom.Element(
834+
localName: 'div', className: 'spoiler-header', nodes: var headerNodes),
835+
dom.Element(
836+
localName: 'div', className: 'spoiler-content', nodes: var contentNodes),
837+
]) {
838+
return SpoilerNode(
839+
header: parseBlockContentList(headerNodes),
840+
content: parseBlockContentList(contentNodes),
841+
);
842+
} else {
843+
return UnimplementedBlockContentNode(htmlNode: divElement);
844+
}
845+
}
846+
812847
BlockContentNode parseCodeBlock(dom.Element divElement) {
813848
assert(_debugParserContext == _ParserContext.block);
814849
final mainElement = () {
@@ -977,6 +1012,10 @@ class _ZulipContentParser {
9771012
parseBlockContentList(element.nodes));
9781013
}
9791014

1015+
if (localName == 'div' && className == 'spoiler-block') {
1016+
return parseSpoilerNode(element);
1017+
}
1018+
9801019
if (localName == 'div' && className == 'codehilite') {
9811020
return parseCodeBlock(element);
9821021
}

lib/widgets/content.dart

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
33
import 'package:flutter/services.dart';
44
import 'package:html/dom.dart' as dom;
55
import 'package:intl/intl.dart';
6+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
67

78
import '../api/core.dart';
89
import '../api/model/model.dart';
@@ -79,6 +80,8 @@ class BlockContentList extends StatelessWidget {
7980
return Quotation(node: node);
8081
} else if (node is ListNode) {
8182
return ListNodeWidget(node: node);
83+
} else if (node is SpoilerNode) {
84+
return Spoiler(node: node);
8285
} else if (node is CodeBlockNode) {
8386
return CodeBlock(node: node);
8487
} else if (node is MathBlockNode) {
@@ -235,6 +238,93 @@ class ListItemWidget extends StatelessWidget {
235238
}
236239
}
237240

241+
class Spoiler extends StatefulWidget {
242+
const Spoiler({super.key, required this.node});
243+
244+
final SpoilerNode node;
245+
246+
@override
247+
State<Spoiler> createState() => _SpoilerState();
248+
}
249+
250+
class _SpoilerState extends State<Spoiler> with TickerProviderStateMixin {
251+
bool expanded = false;
252+
253+
late final AnimationController _controller = AnimationController(
254+
duration: const Duration(milliseconds: 400), vsync: this);
255+
late final Animation<double> _animation = CurvedAnimation(
256+
parent: _controller, curve: Curves.easeInOut);
257+
258+
@override
259+
void dispose() {
260+
_controller.dispose();
261+
super.dispose();
262+
}
263+
264+
void _handleTap() {
265+
setState(() {
266+
if (!expanded) {
267+
_controller.forward();
268+
expanded = true;
269+
} else {
270+
_controller.reverse();
271+
expanded = false;
272+
}
273+
});
274+
}
275+
276+
@override
277+
Widget build(BuildContext context) {
278+
final zulipLocalizations = ZulipLocalizations.of(context);
279+
final header = widget.node.header;
280+
final effectiveHeader = header.isNotEmpty
281+
? header
282+
: [ParagraphNode(links: null,
283+
nodes: [TextNode(zulipLocalizations.spoilerDefaultHeaderText)])];
284+
return Padding(
285+
padding: const EdgeInsets.fromLTRB(0, 5, 0, 15),
286+
child: DecoratedBox(
287+
decoration: BoxDecoration(
288+
border: Border.all(color: const Color(0xff808080)),
289+
borderRadius: BorderRadius.circular(10),
290+
),
291+
child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(10, 2, 8, 2),
292+
child: Column(
293+
children: [
294+
GestureDetector(
295+
behavior: HitTestBehavior.translucent,
296+
onTap: _handleTap,
297+
child: Padding(
298+
padding: const EdgeInsets.all(5),
299+
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
300+
Expanded(
301+
child: DefaultTextStyle.merge(
302+
style: weightVariableTextStyle(context, wght: 700),
303+
child: BlockContentList(
304+
nodes: effectiveHeader))),
305+
RotationTransition(
306+
turns: _animation.drive(Tween(begin: 0, end: 0.5)),
307+
child: const Icon(color: Color(0xffd4d4d4), size: 25,
308+
Icons.expand_more)),
309+
]))),
310+
FadeTransition(
311+
opacity: _animation,
312+
child: const SizedBox(height: 0, width: double.infinity,
313+
child: DecoratedBox(
314+
decoration: BoxDecoration(
315+
border: Border(
316+
bottom: BorderSide(width: 1, color: Color(0xff808080))))))),
317+
SizeTransition(
318+
sizeFactor: _animation,
319+
axis: Axis.vertical,
320+
axisAlignment: -1,
321+
child: Padding(
322+
padding: const EdgeInsets.all(5),
323+
child: BlockContentList(nodes: widget.node.content))),
324+
]))));
325+
}
326+
}
327+
238328
class MessageImageList extends StatelessWidget {
239329
const MessageImageList({super.key, required this.node});
240330

test/flutter_checks.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ extension RouteChecks<T> on Subject<Route<T>> {
3535
Subject<RouteSettings> get settings => has((r) => r.settings, 'settings');
3636
}
3737

38+
extension PageRouteChecks on Subject<PageRoute> {
39+
Subject<bool> get fullscreenDialog => has((x) => x.fullscreenDialog, 'fullscreenDialog');
40+
}
41+
3842
extension RouteSettingsChecks<T> on Subject<RouteSettings> {
3943
Subject<String?> get name => has((s) => s.name, 'name');
4044
Subject<Object?> get arguments => has((s) => s.arguments, 'arguments');

test/model/content_test.dart

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,79 @@ class ContentExample {
125125
const ImageEmojiNode(
126126
src: '/static/generated/emoji/images/emoji/unicode/zulip.png', alt: ':zulip:'));
127127

128+
static const spoilerDefaultHeader = ContentExample(
129+
'spoiler with default header',
130+
'```spoiler\nhello world\n```',
131+
expectedText: 'Spoiler', // or a translation
132+
'<div class="spoiler-block"><div class="spoiler-header">\n'
133+
'</div><div class="spoiler-content" aria-hidden="true">\n'
134+
'<p>hello world</p>\n'
135+
'</div></div>',
136+
[SpoilerNode(
137+
header: [],
138+
content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])],
139+
)]);
140+
141+
static const spoilerPlainCustomHeader = ContentExample(
142+
'spoiler with plain custom header',
143+
'```spoiler hello\nworld\n```',
144+
expectedText: 'hello',
145+
'<div class="spoiler-block"><div class="spoiler-header">\n'
146+
'<p>hello</p>\n'
147+
'</div><div class="spoiler-content" aria-hidden="true">\n'
148+
'<p>world</p>\n'
149+
'</div></div>',
150+
[SpoilerNode(
151+
header: [ParagraphNode(links: null, nodes: [TextNode('hello')])],
152+
content: [ParagraphNode(links: null, nodes: [TextNode('world')])],
153+
)]);
154+
155+
static const spoilerRichHeaderAndContent = ContentExample(
156+
'spoiler with rich header and content',
157+
'```spoiler 1. * ## hello\n*italic* [zulip](https://zulip.com/)\n```',
158+
expectedText: 'hello',
159+
'<div class="spoiler-block"><div class="spoiler-header">\n'
160+
'<ol>\n<li>\n<ul>\n<li>\n<h2>hello</h2>\n</li>\n</ul>\n</li>\n</ol>\n</div>'
161+
'<div class="spoiler-content" aria-hidden="true">\n'
162+
'<p><em>italic</em> <a href="https://zulip.com/">zulip</a></p>\n'
163+
'</div></div>',
164+
[SpoilerNode(
165+
header: [ListNode(ListStyle.ordered, [
166+
[ListNode(ListStyle.unordered, [
167+
[HeadingNode(level: HeadingLevel.h2, links: null, nodes: [
168+
TextNode('hello'),
169+
])]
170+
])],
171+
])],
172+
content: [ParagraphNode(links: null, nodes: [
173+
EmphasisNode(nodes: [TextNode('italic')]),
174+
TextNode(' '),
175+
LinkNode(url: 'https://zulip.com/', nodes: [TextNode('zulip')])
176+
])],
177+
)]);
178+
179+
static const spoilerHeaderHasImage = ContentExample(
180+
'spoiler a header that has an image in it',
181+
'```spoiler [image](https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3)\nhello world\n```',
182+
'<div class="spoiler-block"><div class="spoiler-header">\n'
183+
'<p><a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">image</a></p>\n'
184+
'<div class="message_inline_image"><a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3" title="image"><img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div></div>'
185+
'<div class="spoiler-content" aria-hidden="true">\n'
186+
'<p>hello world</p>\n'
187+
'</div></div>\n',
188+
[SpoilerNode(
189+
header: [
190+
ParagraphNode(links: null, nodes: [
191+
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3',
192+
nodes: [TextNode('image')]),
193+
]),
194+
ImageNodeList([
195+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
196+
]),
197+
],
198+
content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])],
199+
)]);
200+
128201
static const quotation = ContentExample(
129202
'quotation',
130203
"```quote\nwords\n```",
@@ -726,6 +799,11 @@ void main() {
726799
]);
727800
});
728801

802+
testParseExample(ContentExample.spoilerDefaultHeader);
803+
testParseExample(ContentExample.spoilerPlainCustomHeader);
804+
testParseExample(ContentExample.spoilerRichHeaderAndContent);
805+
testParseExample(ContentExample.spoilerHeaderHasImage);
806+
729807
group('track links inside block-inline containers', () {
730808
testParse('multiple links in paragraph',
731809
// "before[text](/there)mid[other](/else)after"

test/widgets/content_test.dart

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'package:zulip/widgets/page.dart';
1515
import 'package:zulip/widgets/store.dart';
1616

1717
import '../example_data.dart' as eg;
18+
import '../flutter_checks.dart';
1819
import '../model/binding.dart';
1920
import '../model/content_test.dart';
2021
import '../model/test_store.dart';
@@ -93,6 +94,97 @@ void main() {
9394
});
9495
});
9596

97+
group('Spoiler', () {
98+
testContentSmoke(ContentExample.spoilerDefaultHeader);
99+
testContentSmoke(ContentExample.spoilerPlainCustomHeader);
100+
testContentSmoke(ContentExample.spoilerRichHeaderAndContent);
101+
102+
group('interactions: spoiler with tappable content (an image) in the header', () {
103+
Future<List<Route<dynamic>>> prepareContent(WidgetTester tester, String html) async {
104+
addTearDown(testBinding.reset);
105+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
106+
prepareBoringImageHttpClient();
107+
108+
final pushedRoutes = <Route<dynamic>>[];
109+
final testNavObserver = TestNavigatorObserver()
110+
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
111+
112+
await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp(
113+
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
114+
supportedLocales: ZulipLocalizations.supportedLocales,
115+
navigatorObservers: [testNavObserver],
116+
home: PerAccountStoreWidget(accountId: eg.selfAccount.id,
117+
child: MessageContent(
118+
message: eg.streamMessage(content: html),
119+
content: parseContent(html))))));
120+
await tester.pump(); // global store
121+
await tester.pump(); // per-account store
122+
debugNetworkImageHttpClientProvider = null;
123+
124+
// `tester.pumpWidget` introduces an initial route;
125+
// remove it so consumers only have newly pushed routes.
126+
assert(pushedRoutes.length == 1);
127+
pushedRoutes.removeLast();
128+
return pushedRoutes;
129+
}
130+
131+
void checkIsExpanded(WidgetTester tester,
132+
bool expected, {
133+
Finder? contentFinder,
134+
}) {
135+
final sizeTransition = tester.widget<SizeTransition>(find.ancestor(
136+
of: contentFinder ?? find.text('hello world'),
137+
matching: find.byType(SizeTransition),
138+
));
139+
check(sizeTransition.sizeFactor)
140+
..value.equals(expected ? 1 : 0)
141+
..status.equals(expected ? AnimationStatus.completed : AnimationStatus.dismissed);
142+
}
143+
144+
const example = ContentExample.spoilerHeaderHasImage;
145+
146+
testWidgets('tap image', (tester) async {
147+
final pushedRoutes = await prepareContent(tester, example.html);
148+
149+
await tester.tap(find.byType(RealmContentNetworkImage));
150+
check(pushedRoutes).single.isA<AccountPageRouteBuilder>()
151+
.fullscreenDialog.isTrue(); // recognize the lightbox
152+
});
153+
154+
testWidgets('tap header on expand/collapse icon', (tester) async {
155+
final pushedRoutes = await prepareContent(tester, example.html);
156+
checkIsExpanded(tester, false);
157+
158+
await tester.tap(find.byIcon(Icons.expand_more));
159+
await tester.pumpAndSettle();
160+
check(pushedRoutes).isEmpty(); // no lightbox
161+
checkIsExpanded(tester, true);
162+
163+
await tester.tap(find.byIcon(Icons.expand_more));
164+
await tester.pumpAndSettle();
165+
check(pushedRoutes).isEmpty(); // no lightbox
166+
checkIsExpanded(tester, false);
167+
});
168+
169+
testWidgets('tap header away from expand/collapse icon (and image)', (tester) async {
170+
final pushedRoutes = await prepareContent(tester, example.html);
171+
checkIsExpanded(tester, false);
172+
173+
await tester.tapAt(
174+
tester.getTopRight(find.byType(RealmContentNetworkImage)) + const Offset(10, 0));
175+
await tester.pumpAndSettle();
176+
check(pushedRoutes).isEmpty(); // no lightbox
177+
checkIsExpanded(tester, true);
178+
179+
await tester.tapAt(
180+
tester.getTopRight(find.byType(RealmContentNetworkImage)) + const Offset(10, 0));
181+
await tester.pumpAndSettle();
182+
check(pushedRoutes).isEmpty(); // no lightbox
183+
checkIsExpanded(tester, false);
184+
});
185+
});
186+
});
187+
96188
testContentSmoke(ContentExample.quotation);
97189

98190
group('MessageImage, MessageImageList', () {

0 commit comments

Comments
 (0)