diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index edb01af23d..95b924fa18 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -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." diff --git a/lib/model/content.dart b/lib/model/content.dart index 1aba6f895d..05067c1d58 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -258,6 +258,21 @@ class QuotationNode extends BlockContentNode { } } +class SpoilerNode extends BlockContentNode { + const SpoilerNode({super.debugHtmlNode, required this.header, required this.content}); + + final List header; + final List content; + + @override + List debugDescribeChildren() { + return [ + _BlockContentListNode(header).toDiagnosticsNode(name: 'header'), + _BlockContentListNode(content).toDiagnosticsNode(name: 'content'), + ]; + } +} + class CodeBlockNode extends BlockContentNode { const CodeBlockNode(this.spans, {super.debugHtmlNode}); @@ -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 = () { @@ -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); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 1f70556f86..1c5d23de87 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -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'; @@ -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) { @@ -235,6 +238,93 @@ class ListItemWidget extends StatelessWidget { } } +class Spoiler extends StatefulWidget { + const Spoiler({super.key, required this.node}); + + final SpoilerNode node; + + @override + State createState() => _SpoilerState(); +} + +class _SpoilerState extends State with TickerProviderStateMixin { + bool expanded = false; + + late final AnimationController _controller = AnimationController( + duration: const Duration(milliseconds: 400), vsync: this); + late final Animation _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}); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 572e176157..8607928c5e 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -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 + '
\n' + '
', + [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', + '
\n' + '

hello

\n' + '
', + [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', + '
\n' + '
    \n
  1. \n
      \n
    • \n

      hello

      \n
    • \n
    \n
  2. \n
\n
' + '
', + [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```', + '
\n' + '

image

\n' + '
\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```", @@ -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" diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 7653dcb55f..d115580127 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -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'; @@ -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>> prepareContent(WidgetTester tester, String html) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + prepareBoringImageHttpClient(); + + final pushedRoutes = >[]; + 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(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() + .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', () { diff --git a/test/widgets/page_checks.dart b/test/widgets/page_checks.dart index f6effa2606..1ce597033a 100644 --- a/test/widgets/page_checks.dart +++ b/test/widgets/page_checks.dart @@ -9,3 +9,7 @@ extension WidgetRouteChecks on Subject { extension AccountPageRouteMixinChecks on Subject { Subject get accountId => has((x) => x.accountId, 'accountId'); } + +extension PageRouteChecks on Subject { + Subject get fullscreenDialog => has((x) => x.fullscreenDialog, 'fullscreenDialog'); +}