Skip to content

content: Support spoilers! #503

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
61 changes: 51 additions & 10 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart';
Expand Down Expand Up @@ -113,6 +114,20 @@ class UnimplementedBlockContentNode extends BlockContentNode
// No ==/hashCode, because htmlNode is a whole subtree.
}

class _BlockContentListNode extends DiagnosticableTree {
const _BlockContentListNode(this.nodes);

final List<BlockContentNode> nodes;

@override
String toStringShort() => 'BlockContentNode list';

@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}

/// A block content node whose children are inline content nodes.
///
/// A node of this type expects a block layout context from its parent,
Expand Down Expand Up @@ -226,33 +241,35 @@ class ListNode extends BlockContentNode {
@override
List<DiagnosticsNode> debugDescribeChildren() {
return items
.map((nodes) => _ListItemDiagnosticableNode(nodes).toDiagnosticsNode())
.mapIndexed((i, nodes) =>
_BlockContentListNode(nodes).toDiagnosticsNode(name: 'item $i'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

content [nfc]: Use toDiagnosticsNode(name: ...)

This commit should probably still have me as the author :-)

(you can use git commit --amend --author=… to adjust)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah indeed; thanks for catching this!

.toList();
}
}

class _ListItemDiagnosticableNode extends DiagnosticableTree {
_ListItemDiagnosticableNode(this.nodes);
class QuotationNode extends BlockContentNode {
const QuotationNode(this.nodes, {super.debugHtmlNode});

final List<BlockContentNode> nodes;

@override
String toStringShort() => 'list item';

@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}

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

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

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

Expand Down Expand Up @@ -807,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 @@ -975,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
4 changes: 4 additions & 0 deletions test/flutter_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ extension RouteChecks<T> on Subject<Route<T>> {
Subject<RouteSettings> get settings => has((r) => r.settings, 'settings');
}

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

extension RouteSettingsChecks<T> on Subject<RouteSettings> {
Subject<String?> get name => has((s) => s.name, 'name');
Subject<Object?> get arguments => has((s) => s.arguments, 'arguments');
Expand Down
78 changes: 78 additions & 0 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,79 @@ 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 +799,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
Loading