Skip to content

Commit

Permalink
content: Handle spoilers
Browse files Browse the repository at this point in the history
Fixes: zulip#358
  • Loading branch information
chrisbobbe committed Feb 24, 2024
1 parent e3e52db commit 62fffc5
Show file tree
Hide file tree
Showing 6 changed files with 306 additions and 0 deletions.
4 changes: 4 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,10 @@
"@serverUrlValidationErrorUnsupportedScheme": {
"description": "Error message when URL has an unsupported scheme."
},
"spoilerDefaultHeaderText": "Spoiler",
"@spoilerDefaultHeaderText": {
"description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )."
},
"markAllAsReadLabel": "Mark all messages as read",
"@markAllAsReadLabel": {
"description": "Button text to mark messages as read."
Expand Down
39 changes: 39 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,21 @@ class QuotationNode extends BlockContentNode {
}
}

class SpoilerNode extends BlockContentNode {
const SpoilerNode({super.debugHtmlNode, required this.header, required this.content});

final List<BlockContentNode> header;
final List<BlockContentNode> content;

@override
List<DiagnosticsNode> debugDescribeChildren() {
return [
_BlockContentListNode(header).toDiagnosticsNode(name: 'header'),
_BlockContentListNode(content).toDiagnosticsNode(name: 'content'),
];
}
}

class CodeBlockNode extends BlockContentNode {
const CodeBlockNode(this.spans, {super.debugHtmlNode});

Expand Down Expand Up @@ -809,6 +824,26 @@ class _ZulipContentParser {
return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode);
}

BlockContentNode parseSpoilerNode(dom.Element divElement) {
assert(_debugParserContext == _ParserContext.block);
assert(divElement.localName == 'div'
&& divElement.className == 'spoiler-block');

if (divElement.nodes case [
dom.Element(
localName: 'div', className: 'spoiler-header', nodes: var headerNodes),
dom.Element(
localName: 'div', className: 'spoiler-content', nodes: var contentNodes),
]) {
return SpoilerNode(
header: parseBlockContentList(headerNodes),
content: parseBlockContentList(contentNodes),
);
} else {
return UnimplementedBlockContentNode(htmlNode: divElement);
}
}

BlockContentNode parseCodeBlock(dom.Element divElement) {
assert(_debugParserContext == _ParserContext.block);
final mainElement = () {
Expand Down Expand Up @@ -977,6 +1012,10 @@ class _ZulipContentParser {
parseBlockContentList(element.nodes));
}

if (localName == 'div' && className == 'spoiler-block') {
return parseSpoilerNode(element);
}

if (localName == 'div' && className == 'codehilite') {
return parseCodeBlock(element);
}
Expand Down
90 changes: 90 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:html/dom.dart' as dom;
import 'package:intl/intl.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';

import '../api/core.dart';
import '../api/model/model.dart';
Expand Down Expand Up @@ -79,6 +80,8 @@ class BlockContentList extends StatelessWidget {
return Quotation(node: node);
} else if (node is ListNode) {
return ListNodeWidget(node: node);
} else if (node is SpoilerNode) {
return Spoiler(node: node);
} else if (node is CodeBlockNode) {
return CodeBlock(node: node);
} else if (node is MathBlockNode) {
Expand Down Expand Up @@ -235,6 +238,93 @@ class ListItemWidget extends StatelessWidget {
}
}

class Spoiler extends StatefulWidget {
const Spoiler({super.key, required this.node});

final SpoilerNode node;

@override
State<Spoiler> createState() => _SpoilerState();
}

class _SpoilerState extends State<Spoiler> with TickerProviderStateMixin {
bool expanded = false;

late final AnimationController _controller = AnimationController(
duration: const Duration(milliseconds: 400), vsync: this);
late final Animation<double> _animation = CurvedAnimation(
parent: _controller, curve: Curves.easeInOut);

@override
void dispose() {
_controller.dispose();
super.dispose();
}

void _handleTap() {
setState(() {
if (!expanded) {
_controller.forward();
expanded = true;
} else {
_controller.reverse();
expanded = false;
}
});
}

@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
final header = widget.node.header;
final effectiveHeader = header.isNotEmpty
? header
: [ParagraphNode(links: null,
nodes: [TextNode(zulipLocalizations.spoilerDefaultHeaderText)])];
return Padding(
padding: const EdgeInsets.fromLTRB(0, 5, 0, 15),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: const Color(0xff808080)),
borderRadius: BorderRadius.circular(10),
),
child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(10, 2, 8, 2),
child: Column(
children: [
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _handleTap,
child: Padding(
padding: const EdgeInsets.all(5),
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
Expanded(
child: DefaultTextStyle.merge(
style: weightVariableTextStyle(context, wght: 700),
child: BlockContentList(
nodes: effectiveHeader))),
RotationTransition(
turns: _animation.drive(Tween(begin: 0, end: 0.5)),
child: const Icon(color: Color(0xffd4d4d4), size: 25,
Icons.expand_more)),
]))),
FadeTransition(
opacity: _animation,
child: const SizedBox(height: 0, width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(width: 1, color: Color(0xff808080))))))),
SizeTransition(
sizeFactor: _animation,
axis: Axis.vertical,
axisAlignment: -1,
child: Padding(
padding: const EdgeInsets.all(5),
child: BlockContentList(nodes: widget.node.content))),
]))));
}
}

class MessageImageList extends StatelessWidget {
const MessageImageList({super.key, required this.node});

Expand Down
77 changes: 77 additions & 0 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,78 @@ class ContentExample {
const ImageEmojiNode(
src: '/static/generated/emoji/images/emoji/unicode/zulip.png', alt: ':zulip:'));

static const spoilerDefaultHeader = ContentExample(
'spoiler with default header',
'```spoiler\nhello world\n```',
expectedText: 'Spoiler', // or a translation
'<div class="spoiler-block"><div class="spoiler-header">\n'
'</div><div class="spoiler-content" aria-hidden="true">\n'
'<p>hello world</p>\n'
'</div></div>',
[SpoilerNode(
header: [],
content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])],
)]);

static const spoilerPlainCustomHeader = ContentExample(
'spoiler with plain custom header',
'```spoiler hello\nworld\n```',
expectedText: 'hello',
'<div class="spoiler-block"><div class="spoiler-header">\n'
'<p>hello</p>\n'
'</div><div class="spoiler-content" aria-hidden="true">\n'
'<p>world</p>\n'
'</div></div>',
[SpoilerNode(
header: [ParagraphNode(links: null, nodes: [TextNode('hello')])],
content: [ParagraphNode(links: null, nodes: [TextNode('world')])],
)]);

static const spoilerRichHeaderAndContent = ContentExample(
'spoiler with rich header and content',
'```spoiler 1. * ## hello\n*italic* [zulip](https://zulip.com/)\n```',
expectedText: 'hello',
'<div class="spoiler-block"><div class="spoiler-header">\n'
'<ol>\n<li>\n<ul>\n<li>\n<h2>hello</h2>\n</li>\n</ul>\n</li>\n</ol>\n</div>'
'<div class="spoiler-content" aria-hidden="true">\n'
'<p><em>italic</em> <a href="https://zulip.com/">zulip</a></p>\n'
'</div></div>',
[SpoilerNode(
header: [ListNode(ListStyle.ordered, [
[ListNode(ListStyle.unordered, [
[HeadingNode(level: HeadingLevel.h2, links: null, nodes: [
TextNode('hello'),
])]
])],
])],
content: [ParagraphNode(links: null, nodes: [
EmphasisNode(nodes: [TextNode('italic')]),
TextNode(' '),
LinkNode(url: 'https://zulip.com/', nodes: [TextNode('zulip')])
])],
)]);

static const spoilerHeaderHasImage = ContentExample(
'spoiler a header that has an image in it',
'```spoiler [image](https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3)\nhello world\n```',
'<div class="spoiler-block"><div class="spoiler-header">\n'
'<p><a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">image</a></p>\n'
'<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><div class="spoiler-content" aria-hidden="true">\n'
'<p>hello world</p>\n'
'</div></div>\n',
[SpoilerNode(
header: [
ParagraphNode(links: null, nodes: [
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3',
nodes: [TextNode('image')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
]),
],
content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])],
)]);

static const quotation = ContentExample(
'quotation',
"```quote\nwords\n```",
Expand Down Expand Up @@ -726,6 +798,11 @@ void main() {
]);
});

testParseExample(ContentExample.spoilerDefaultHeader);
testParseExample(ContentExample.spoilerPlainCustomHeader);
testParseExample(ContentExample.spoilerRichHeaderAndContent);
testParseExample(ContentExample.spoilerHeaderHasImage);

group('track links inside block-inline containers', () {
testParse('multiple links in paragraph',
// "before[text](/there)mid[other](/else)after"
Expand Down
92 changes: 92 additions & 0 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:zulip/widgets/page.dart';
import 'package:zulip/widgets/store.dart';

import '../example_data.dart' as eg;
import '../flutter_checks.dart';
import '../model/binding.dart';
import '../model/content_test.dart';
import '../model/test_store.dart';
Expand Down Expand Up @@ -93,6 +94,97 @@ void main() {
});
});

group('Spoiler', () {
testContentSmoke(ContentExample.spoilerDefaultHeader);
testContentSmoke(ContentExample.spoilerPlainCustomHeader);
testContentSmoke(ContentExample.spoilerRichHeaderAndContent);

group('interactions: spoiler with tappable content (an image) in the header', () {
Future<List<Route<dynamic>>> prepareContent(WidgetTester tester, String html) async {
addTearDown(testBinding.reset);
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
prepareBoringImageHttpClient();

final pushedRoutes = <Route<dynamic>>[];
final testNavObserver = TestNavigatorObserver()
..onPushed = (route, prevRoute) => pushedRoutes.add(route);

await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp(
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
supportedLocales: ZulipLocalizations.supportedLocales,
navigatorObservers: [testNavObserver],
home: PerAccountStoreWidget(accountId: eg.selfAccount.id,
child: MessageContent(
message: eg.streamMessage(content: html),
content: parseContent(html))))));
await tester.pump(); // global store
await tester.pump(); // per-account store
debugNetworkImageHttpClientProvider = null;

// `tester.pumpWidget` introduces an initial route;
// remove it so consumers only have newly pushed routes.
assert(pushedRoutes.length == 1);
pushedRoutes.removeLast();
return pushedRoutes;
}

void checkIsExpanded(WidgetTester tester,
bool expected, {
Finder? contentFinder,
}) {
final sizeTransition = tester.widget<SizeTransition>(find.ancestor(
of: contentFinder ?? find.text('hello world'),
matching: find.byType(SizeTransition),
));
check(sizeTransition.sizeFactor)
..value.equals(expected ? 1 : 0)
..status.equals(expected ? AnimationStatus.completed : AnimationStatus.dismissed);
}

const example = ContentExample.spoilerHeaderHasImage;

testWidgets('tap image', (tester) async {
final pushedRoutes = await prepareContent(tester, example.html);

await tester.tapAt(tester.getCenter(find.byType(RealmContentNetworkImage)));
check(pushedRoutes).single.isA<AccountPageRouteBuilder>()
.fullscreenDialog.isTrue(); // recognize the lightbox
});

testWidgets('tap header on expand/collapse icon', (tester) async {
final pushedRoutes = await prepareContent(tester, example.html);
checkIsExpanded(tester, false);

await tester.tap(find.byIcon(Icons.expand_more));
await tester.pumpAndSettle();
check(pushedRoutes).isEmpty(); // no lightbox
checkIsExpanded(tester, true);

await tester.tap(find.byIcon(Icons.expand_more));
await tester.pumpAndSettle();
check(pushedRoutes).isEmpty(); // no lightbox
checkIsExpanded(tester, false);
});

testWidgets('tap header away from expand/collapse icon (and image)', (tester) async {
final pushedRoutes = await prepareContent(tester, example.html);
checkIsExpanded(tester, false);

await tester.tapAt(
tester.getTopRight(find.byType(RealmContentNetworkImage)) + const Offset(10, 0));
await tester.pumpAndSettle();
check(pushedRoutes).isEmpty(); // no lightbox
checkIsExpanded(tester, true);

await tester.tapAt(
tester.getTopRight(find.byType(RealmContentNetworkImage)) + const Offset(10, 0));
await tester.pumpAndSettle();
check(pushedRoutes).isEmpty(); // no lightbox
checkIsExpanded(tester, false);
});
});
});

testContentSmoke(ContentExample.quotation);

group('MessageImage, MessageImageList', () {
Expand Down
4 changes: 4 additions & 0 deletions test/widgets/page_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ extension WidgetRouteChecks on Subject<WidgetRoute> {
extension AccountPageRouteMixinChecks on Subject<AccountPageRouteMixin> {
Subject<int> get accountId => has((x) => x.accountId, 'accountId');
}

extension PageRouteChecks on Subject<PageRoute> {
Subject<bool> get fullscreenDialog => has((x) => x.fullscreenDialog, 'fullscreenDialog');
}

0 comments on commit 62fffc5

Please sign in to comment.