From c66a9053eed5ce60a010ceefea98f92e6f09c43f Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 5 Feb 2025 18:45:16 -0500 Subject: [PATCH 001/110] android build: Allow Gradle to use even more memory, 4 GiB We are pretty certain that the upstream commit https://github.com/flutter/flutter/commit/df114fbe9 was the exact one that caused the increased RAM usage for the Android build. The upstream change causes the engine artifacts to contain debug symbols, which is intentional (as it enables putting debug symbols in the app's bundle artifact) but makes the artifacts about 8x larger. That causes known regressions in CPU and memory use at build time: https://github.com/flutter/flutter/issues/162675 Since the regression was expected, there is no action to be taken upstream. However, we haven't found an effective way to rewrite the build script in a way that this is mitigated without needing to raise the limit. For the investigation details, see CZO discussion: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/Gradle.20out.20of.20memory Since a previous bump to 3 GiB, the issue has been mitigated, but it still happens some of the time: at least twice in the past day or so. Add another 1 GiB to see if that addresses the flakes. --- android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 2974fbcb00..b0202e3120 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx3072M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true From aa53cf60281f805043066a5d480c8151ab7cb3c1 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 23 Jan 2025 21:46:57 -0800 Subject: [PATCH 002/110] sticky_header: Add example app This is adapted lightly from an example app I made back in the first week of the zulip-flutter project, 2022-12-23, as part of developing the sticky_header library. The example app was extremely helpful then for experimenting with changes and seeing the effects visually and interactively, as well as for print-debugging such experiments. So let's get it into the tree. The main reason I didn't send the example app back then is that it was a whole stand-alone app tree under example/, complete with all the stuff in android/ and ios/ and so on that `flutter create` spits out for setting up a Flutter app. That's pretty voluminous: well over 100 different files totalling about 1.1MB on disk. I did't want to permanently burden the repo with all that, nor have to maintain it all over time. Happily, I realized today that we can skip that, and still have a perfectly good example app, by reusing that infrastructure from the actual Zulip app. That way all we need is a Dart file with a `main` function, corresponding to the old example's `lib/main.dart` which was the only not-from-the-template code in the whole example app. So here it is. Other than moving the Dart file and discarding the rest, the code I wrote back then has been updated to our current formatting style; adjusted slightly for changes in Flutter's Material widgets; and updated for changes I made to the sticky_header API after that first week. --- lib/example/sticky_header.dart | 345 +++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 lib/example/sticky_header.dart diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart new file mode 100644 index 0000000000..62cfe35310 --- /dev/null +++ b/lib/example/sticky_header.dart @@ -0,0 +1,345 @@ +/// Example app for exercising the sticky_header library. +/// +/// This is useful when developing changes to [StickyHeaderListView], +/// [SliverStickyHeaderList], and [StickyHeaderItem], +/// for experimenting visually with changes. +/// +/// To use this example app, run the command: +/// flutter run lib/example/sticky_header.dart +/// or run this file from your IDE. +/// +/// One inconvenience: this means the example app will use the same app ID +/// as the actual Zulip app. The app's data remains untouched, though, so +/// a normal `flutter run` will put things back as they were. +/// This inconvenience could be fixed with a bit more work: we'd use +/// `flutter run --flavor`, and define an Android flavor in build.gradle +/// and an Xcode scheme in the iOS build config +/// so as to set the app ID differently. +library; + +import 'package:flutter/material.dart'; + +import '../widgets/sticky_header.dart'; + +/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a +/// vertically-scrolling list. +class ExampleVertical extends StatelessWidget { + ExampleVertical({ + super.key, + required this.title, + this.reverse = false, + this.headerDirection = AxisDirection.down, + }) : assert(axisDirectionToAxis(headerDirection) == Axis.vertical); + + final String title; + final bool reverse; + final AxisDirection headerDirection; + + @override + Widget build(BuildContext context) { + final headerAtBottom = axisDirectionIsReversed(headerDirection); + + const numSections = 100; + const numPerSection = 10; + return Scaffold( + appBar: AppBar(title: Text(title)), + + // Invoke StickyHeaderListView the same way you'd invoke ListView. + // The constructor takes the same arguments. + body: StickyHeaderListView.separated( + reverse: reverse, + reverseHeader: headerAtBottom, + itemCount: numSections, + separatorBuilder: (context, i) => const SizedBox.shrink(), + + // Use StickyHeaderItem as an item widget in the ListView. + // A header will float over the item as needed in order to + // "stick" at the edge of the viewport. + // + // You can also include non-StickyHeaderItem items in the list. + // They'll behave just like in a plain ListView. + // + // Each StickyHeaderItem needs to be an item directly in the list, not + // wrapped inside other widgets that affect layout, in order to get + // the sticky-header behavior. + itemBuilder: (context, i) => StickyHeaderItem( + header: WideHeader(i: i), + child: Column( + verticalDirection: headerAtBottom + ? VerticalDirection.up : VerticalDirection.down, + children: List.generate( + numPerSection + 1, (j) { + if (j == 0) return WideHeader(i: i); + return WideItem(i: i, j: j-1); + }))))); + } +} + +/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a +/// horizontally-scrolling list. +class ExampleHorizontal extends StatelessWidget { + ExampleHorizontal({ + super.key, + required this.title, + this.reverse = false, + required this.headerDirection, + }) : assert(axisDirectionToAxis(headerDirection) == Axis.horizontal); + + final String title; + final bool reverse; + final AxisDirection headerDirection; + + @override + Widget build(BuildContext context) { + final headerAtRight = axisDirectionIsReversed(headerDirection); + const numSections = 100; + const numPerSection = 10; + return Scaffold( + appBar: AppBar(title: Text(title)), + body: StickyHeaderListView.separated( + + // StickyHeaderListView and StickyHeaderItem also work for horizontal + // scrolling. Pass `scrollDirection: Axis.horizontal` to the + // StickyHeaderListView constructor, just like for ListView. + scrollDirection: Axis.horizontal, + reverse: reverse, + reverseHeader: headerAtRight, + itemCount: numSections, + separatorBuilder: (context, i) => const SizedBox.shrink(), + itemBuilder: (context, i) => StickyHeaderItem( + header: TallHeader(i: i), + child: Row( + textDirection: headerAtRight ? TextDirection.rtl : TextDirection.ltr, + children: List.generate( + numPerSection + 1, + (j) { + if (j == 0) return TallHeader(i: i); + return TallItem(i: i, j: j-1, numPerSection: numPerSection); + }))))); + } +} + +//////////////////////////////////////////////////////////////////////////// +// +// That's it! +// +// The rest of this file is boring infrastructure for navigating to the +// different examples, and for having some content to put inside them. +// +//////////////////////////////////////////////////////////////////////////// + +class WideHeader extends StatelessWidget { + const WideHeader({super.key, required this.i}); + + final int i; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.primaryContainer, + child: ListTile( + title: Text("Section ${i + 1}", + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer)))); + } +} + +class WideItem extends StatelessWidget { + const WideItem({super.key, required this.i, required this.j}); + + final int i; + final int j; + + @override + Widget build(BuildContext context) { + return ListTile(title: Text("Item ${i + 1}.${j + 1}")); + } +} + +class TallHeader extends StatelessWidget { + const TallHeader({super.key, required this.i}); + + final int i; + + @override + Widget build(BuildContext context) { + final contents = Column(children: [ + Text("Section ${i + 1}", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer)), + const SizedBox(height: 8), + const Expanded(child: SizedBox.shrink()), + const SizedBox(height: 8), + const Text("end"), + ]); + + return Container( + alignment: Alignment.center, + child: Card( + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding(padding: const EdgeInsets.all(8), child: contents))); + } +} + +class TallItem extends StatelessWidget { + const TallItem({super.key, + required this.i, + required this.j, + required this.numPerSection, + }); + + final int i; + final int j; + final int numPerSection; + + @override + Widget build(BuildContext context) { + final heightFactor = (1 + j) / numPerSection; + + final contents = Column(children: [ + Text("Item ${i + 1}.${j + 1}"), + const SizedBox(height: 8), + Expanded( + child: FractionallySizedBox( + heightFactor: heightFactor, + child: ColoredBox( + color: Theme.of(context).colorScheme.secondary, + child: const SizedBox(width: 4)))), + const SizedBox(height: 8), + const Text("end"), + ]); + + return Container( + alignment: Alignment.center, + child: Card( + child: Padding(padding: const EdgeInsets.all(8), child: contents))); + } +} + +enum _ExampleType { vertical, horizontal } + +class MainPage extends StatelessWidget { + const MainPage({super.key}); + + @override + Widget build(BuildContext context) { + final verticalItems = [ + _buildItem(context, _ExampleType.vertical, + primary: true, + title: 'Scroll down, headers at top (a standard list)', + headerDirection: AxisDirection.down), + _buildItem(context, _ExampleType.vertical, + title: 'Scroll up, headers at top', + reverse: true, + headerDirection: AxisDirection.down), + _buildItem(context, _ExampleType.vertical, + title: 'Scroll down, headers at bottom', + headerDirection: AxisDirection.up), + _buildItem(context, _ExampleType.vertical, + title: 'Scroll up, headers at bottom', + reverse: true, + headerDirection: AxisDirection.up), + ]; + final horizontalItems = [ + _buildItem(context, _ExampleType.horizontal, + title: 'Scroll right, headers at left', + headerDirection: AxisDirection.right), + _buildItem(context, _ExampleType.horizontal, + title: 'Scroll left, headers at left', + reverse: true, + headerDirection: AxisDirection.right), + _buildItem(context, _ExampleType.horizontal, + title: 'Scroll right, headers at right', + headerDirection: AxisDirection.left), + _buildItem(context, _ExampleType.horizontal, + title: 'Scroll left, headers at right', + reverse: true, + headerDirection: AxisDirection.left), + ]; + return Scaffold( + appBar: AppBar(title: const Text('Sticky Headers example')), + body: CustomScrollView(slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: Center( + child: Text("Vertical lists", + style: Theme.of(context).textTheme.headlineMedium)))), + SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + sliver: SliverGrid.count( + childAspectRatio: 2, + crossAxisCount: 2, + children: verticalItems)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: Center( + child: Text("Horizontal lists", + style: Theme.of(context).textTheme.headlineMedium)))), + SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + sliver: SliverGrid.count( + childAspectRatio: 2, + crossAxisCount: 2, + children: horizontalItems)), + ])); + } + + Widget _buildItem(BuildContext context, _ExampleType exampleType, { + required String title, + bool reverse = false, + required AxisDirection headerDirection, + bool primary = false, + }) { + Widget page; + switch (exampleType) { + case _ExampleType.vertical: + page = ExampleVertical( + title: title, reverse: reverse, headerDirection: headerDirection); + break; + case _ExampleType.horizontal: + page = ExampleHorizontal( + title: title, reverse: reverse, headerDirection: headerDirection); + break; + } + + var label = Text(title, + textAlign: TextAlign.center, + style: TextStyle( + inherit: true, + fontSize: Theme.of(context).textTheme.titleMedium?.fontSize)); + var buttonStyle = primary + ? null + : ElevatedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSecondary, + backgroundColor: Theme.of(context).colorScheme.secondary); + return Container( + padding: const EdgeInsets.all(16), + child: ElevatedButton( + style: buttonStyle, + onPressed: () => Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => page)), + child: label)); + } +} + +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Sticky Headers example', + theme: ThemeData( + colorScheme: + ColorScheme.fromSeed(seedColor: const Color(0xff3366cc))), + home: const MainPage(), + ); + } +} + +void main() { + runApp(const ExampleApp()); +} From 7814138e252b4d9d7152cf3897fffb5844fa8e1e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 30 Jan 2024 12:10:28 -0800 Subject: [PATCH 003/110] sticky_header test [nfc]: Cut a commented-out debug print --- test/widgets/sticky_header_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index fc363c71e8..e9a2210cb0 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -174,7 +174,6 @@ Future _checkSequence( final expectedHeaderIndex = first ? (scrollOffset / 100).floor() : (extent ~/ 100 - 1) + (scrollOffset / 100).ceil(); - // print("$scrollOffset, $extent, $expectedHeaderIndex"); check(tester.widget<_Item>(itemFinder).index).equals(expectedHeaderIndex); check(_headerIndex(tester)).equals(expectedHeaderIndex); From b39f6a9cd729c5dec0ef10adf6edcaf99a103135 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 30 Jan 2025 16:52:51 -0800 Subject: [PATCH 004/110] sticky_header [nfc]: Add comments about child sliver --- lib/widgets/sticky_header.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 356a056094..fc9b79a85a 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -489,6 +489,11 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper if (_header != null) adoptChild(_header!); } + /// This sliver's child sliver, a modified [RenderSliverList]. + /// + /// The child manages the items in the list (deferring to [RenderSliverList]); + /// and identifies which list item, if any, should be consulted + /// for a sticky header. _RenderSliverStickyHeaderListInner? get child => _child; _RenderSliverStickyHeaderListInner? _child; set child(_RenderSliverStickyHeaderListInner? value) { @@ -552,6 +557,9 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper @override void performLayout() { + // First, lay out the child sliver. This does all the normal work of + // [RenderSliverList], then calls [_rebuildHeader] on this sliver + // so that [header] and [_headerEndBound] are up to date. assert(child != null); child!.layout(constraints, parentUsesSize: true); SliverGeometry geometry = child!.geometry!; From 40bc4bc5c5eeaab0ed21f51307e984b6f6610e5e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 24 Jan 2025 23:18:33 -0800 Subject: [PATCH 005/110] sticky_header [nfc]: Add comments on _headerEndBound conditions --- lib/widgets/sticky_header.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index fc9b79a85a..2f317aa4f8 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -570,6 +570,11 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper final headerExtent = header!.size.onAxis(constraints.axis); final double headerOffset; if (_headerEndBound == null) { + // The header's item has [StickyHeaderItem.allowOverflow] true. + // Show the header in full, with one edge at the edge of the viewport, + // even if the (visible part of the) item is smaller than the header, + // and even if the whole child sliver is smaller than the header. + final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); final cacheExtent = calculateCacheOffset(constraints, from: 0, to: headerExtent); @@ -590,6 +595,10 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper ? geometry.layoutExtent - headerExtent : 0.0; } else { + // The header's item has [StickyHeaderItem.allowOverflow] false. + // Keep the header within the item, pushing the header partly out of + // the viewport if the item's visible part is smaller than the header. + // The limiting edge of the header's item, // in the outer, non-scrolling coordinates. final endBoundAbsolute = axisDirectionIsReversed(constraints.growthAxisDirection) From 40ac6de66269475c9c86060d57649eee30ec9436 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 24 Jan 2025 23:19:26 -0800 Subject: [PATCH 006/110] sticky_header [nfc]: Cut redundant assert on header size It's already a fact that the header's size in each dimension is non-negative and finite; the framework asserts that in the `layout` implementation (via debugAssertDoesMeetConstraints). So that includes `headerExtent`; and then `paintedHeaderSize` is bounded to between zero and that value. --- lib/widgets/sticky_header.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 2f317aa4f8..5e675f5244 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -566,8 +566,8 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper if (header != null) { header!.layout(constraints.asBoxConstraints(), parentUsesSize: true); - final headerExtent = header!.size.onAxis(constraints.axis); + final double headerOffset; if (_headerEndBound == null) { // The header's item has [StickyHeaderItem.allowOverflow] true. @@ -578,8 +578,6 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); final cacheExtent = calculateCacheOffset(constraints, from: 0, to: headerExtent); - assert(0 <= paintedHeaderSize && paintedHeaderSize.isFinite); - geometry = SliverGeometry( // TODO review interaction with other slivers scrollExtent: geometry.scrollExtent, layoutExtent: geometry.layoutExtent, From ff252d78436e2ebb40677cec80006e5b5cbcc2d0 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 27 Jan 2025 13:42:30 -0800 Subject: [PATCH 007/110] sticky_header: Use cacheExtent from child This is the right thing, as the comment explains. Conveniently it's also simpler. --- lib/widgets/sticky_header.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 5e675f5244..f40b2c0643 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -576,17 +576,21 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper // and even if the whole child sliver is smaller than the header. final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); - final cacheExtent = calculateCacheOffset(constraints, from: 0, to: headerExtent); - geometry = SliverGeometry( // TODO review interaction with other slivers scrollExtent: geometry.scrollExtent, layoutExtent: geometry.layoutExtent, paintExtent: math.max(geometry.paintExtent, paintedHeaderSize), - cacheExtent: math.max(geometry.cacheExtent, cacheExtent), maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), hitTestExtent: math.max(geometry.hitTestExtent, paintedHeaderSize), hasVisualOverflow: geometry.hasVisualOverflow || headerExtent > constraints.remainingPaintExtent, + + // The cache extent is an extension of layout, not paint; it controls + // where the next sliver should start laying out content. (See + // [SliverConstraints.remainingCacheExtent].) The header isn't meant + // to affect where the next sliver gets laid out, so it shouldn't + // affect the cache extent. + cacheExtent: geometry.cacheExtent, ); headerOffset = _headerAtCoordinateEnd() From 366144d04b4b12c18ae0172b9e320ab2493fb154 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 24 Jan 2025 22:34:35 -0800 Subject: [PATCH 008/110] sticky_header [nfc]: Add asserts from studying possible child geometry; note a bug The logic below -- particularly in the allowOverflow true case, where it constructs a new SliverGeometry from scratch -- has been implicitly relying on several of these facts already. These wouldn't be true of an arbitrary sliver child; but this sliver knows what type of child it actually has, and they are true of that one. So write down the specific assumptions we can take from that. Reading through [RenderSliverList.performLayout] to see what it can produce as the child geometry also made clear there's another case that this method isn't currently correctly handling at all: the case where scrollOffsetCorrection is non-null. Filed an issue for that; add a todo-comment for it. --- lib/widgets/sticky_header.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index f40b2c0643..18df9adab3 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -564,6 +564,19 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper child!.layout(constraints, parentUsesSize: true); SliverGeometry geometry = child!.geometry!; + // TODO(#1309) handle the scrollOffsetCorrection case, passing it through + + // We assume [child]'s geometry is free of certain complications. + // Probably most or all of these *could* be handled if necessary, just at + // the cost of further complicating this code. Fortunately they aren't, + // because [RenderSliverList.performLayout] never has these complications. + assert(geometry.paintOrigin == 0); + assert(geometry.layoutExtent == geometry.paintExtent); + assert(geometry.hitTestExtent == geometry.paintExtent); + assert(geometry.visible == (geometry.paintExtent > 0)); + assert(geometry.maxScrollObstructionExtent == 0); + assert(geometry.crossAxisExtent == null); + if (header != null) { header!.layout(constraints.asBoxConstraints(), parentUsesSize: true); final headerExtent = header!.size.onAxis(constraints.axis); From 66cc72771cb411eeb840a5254cb1097bad2aedb4 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 28 Jan 2025 14:10:39 -0800 Subject: [PATCH 009/110] sticky_header: Handle scrollOffsetCorrection Fixes #1309. Fixes #725. This is necessary when scrolling back to the origin over items that grew taller while off screen (and beyond the 250px of near-on-screen cached items). For example that can happen because a message was edited, or because new messages came in that were taller than those previously present. --- lib/widgets/sticky_header.dart | 5 ++- test/widgets/sticky_header_test.dart | 50 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 18df9adab3..47e2b6e302 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -564,7 +564,10 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper child!.layout(constraints, parentUsesSize: true); SliverGeometry geometry = child!.geometry!; - // TODO(#1309) handle the scrollOffsetCorrection case, passing it through + if (geometry.scrollOffsetCorrection != null) { + this.geometry = geometry; + return; + } // We assume [child]'s geometry is free of certain complications. // Probably most or all of these *could* be handled if necessary, just at diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index e9a2210cb0..611209b2f8 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -103,6 +103,56 @@ void main() { } } } + + testWidgets('sticky headers: propagate scrollOffsetCorrection properly', (tester) async { + Widget page(Widget Function(BuildContext, int) itemBuilder) { + return Directionality(textDirection: TextDirection.ltr, + child: StickyHeaderListView.builder( + cacheExtent: 0, + itemCount: 10, itemBuilder: itemBuilder)); + } + + await tester.pumpWidget(page((context, i) => + StickyHeaderItem( + allowOverflow: true, + header: _Header(i, height: 40), + child: _Item(i, height: 200)))); + check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 400)); + + // Scroll down (dragging up) to get item 0 off screen. + await tester.drag(find.text("Item 2"), Offset(0, -300)); + await tester.pump(); + check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 100)); + + // Make the off-screen item 0 taller, so scrolling back up will underflow. + await tester.pumpWidget(page((context, i) => + StickyHeaderItem( + allowOverflow: true, + header: _Header(i, height: 40), + child: _Item(i, height: i == 0 ? 400 : 200)))); + // Confirm the change in item 0's height hasn't already been applied, + // as it would if the item were within the viewport or its cache area. + check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 100)); + + // Scroll back up (dragging down). This will cause a correction as the list + // discovers that moving 300px up doesn't reach the start anymore. + await tester.drag(find.text("Item 2"), Offset(0, 300)); + + // As a bonus, mark one of the already-visible items as needing layout. + // (In a real app, this would typically happen because some state changed.) + tester.firstElement(find.widgetWithText(SizedBox, "Item 2")) + .renderObject!.markNeedsLayout(); + + // If scrollOffsetCorrection doesn't get propagated to the viewport, this + // pump will record an exception (causing the test to fail at the end) + // because the marked item won't get laid out. + await tester.pump(); + check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 400)); + + // Moreover if scrollOffsetCorrection doesn't get propagated, this item + // will get placed at zero rather than properly extend up off screen. + check(tester.getTopLeft(find.text("Item 0"))).equals(Offset(0, -200)); + }); } Future _checkSequence( From 4f80b303316dcf2c8a1f0af253ff9a7b53df8e80 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 24 Jan 2025 22:43:10 -0800 Subject: [PATCH 010/110] sticky_header [nfc]: Skip hitTestExtent, further using simplifying assumptions Because the child's hitTestExtent equals its paintExtent, this was producing a new hitTestExtent equal to the new paintExtent. But that's the same behavior the SliverGeometry constructor gives us by default. --- lib/widgets/sticky_header.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 47e2b6e302..4b6a9d29b4 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -597,7 +597,6 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper layoutExtent: geometry.layoutExtent, paintExtent: math.max(geometry.paintExtent, paintedHeaderSize), maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), - hitTestExtent: math.max(geometry.hitTestExtent, paintedHeaderSize), hasVisualOverflow: geometry.hasVisualOverflow || headerExtent > constraints.remainingPaintExtent, From a702bd2b09ca0368065ea5c7bbc235c5b06cb94a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 24 Jan 2025 22:47:18 -0800 Subject: [PATCH 011/110] sticky_header [nfc]: Explicitly use single "childExtent" This relies on (and expresses) an assumption in the new assertions: that the child's layoutExtent equals paintExtent. That assumption will be helpful in keeping this logic manageable to understand as we add an upcoming further wrinkle. --- lib/widgets/sticky_header.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 4b6a9d29b4..9442ea04eb 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -579,6 +579,7 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper assert(geometry.visible == (geometry.paintExtent > 0)); assert(geometry.maxScrollObstructionExtent == 0); assert(geometry.crossAxisExtent == null); + final childExtent = geometry.layoutExtent; if (header != null) { header!.layout(constraints.asBoxConstraints(), parentUsesSize: true); @@ -594,8 +595,8 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); geometry = SliverGeometry( // TODO review interaction with other slivers scrollExtent: geometry.scrollExtent, - layoutExtent: geometry.layoutExtent, - paintExtent: math.max(geometry.paintExtent, paintedHeaderSize), + layoutExtent: childExtent, + paintExtent: math.max(childExtent, paintedHeaderSize), maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), hasVisualOverflow: geometry.hasVisualOverflow || headerExtent > constraints.remainingPaintExtent, @@ -609,7 +610,7 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper ); headerOffset = _headerAtCoordinateEnd() - ? geometry.layoutExtent - headerExtent + ? childExtent - headerExtent : 0.0; } else { // The header's item has [StickyHeaderItem.allowOverflow] false. @@ -619,11 +620,11 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper // The limiting edge of the header's item, // in the outer, non-scrolling coordinates. final endBoundAbsolute = axisDirectionIsReversed(constraints.growthAxisDirection) - ? geometry.layoutExtent - (_headerEndBound! - constraints.scrollOffset) + ? childExtent - (_headerEndBound! - constraints.scrollOffset) : _headerEndBound! - constraints.scrollOffset; headerOffset = _headerAtCoordinateEnd() - ? math.max(geometry.layoutExtent - headerExtent, endBoundAbsolute) + ? math.max(childExtent - headerExtent, endBoundAbsolute) : math.min(0.0, endBoundAbsolute - headerExtent); } From 5ad545bb971023d2822d073b537f7aac302b2591 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 23 Jan 2025 21:36:45 -0800 Subject: [PATCH 012/110] sticky_header example: Add a double-sliver example --- lib/example/sticky_header.dart | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart index 62cfe35310..3fa2185c1a 100644 --- a/lib/example/sticky_header.dart +++ b/lib/example/sticky_header.dart @@ -119,6 +119,65 @@ class ExampleHorizontal extends StatelessWidget { } } +/// An experimental example approximating the Zulip message list. +class ExampleVerticalDouble extends StatelessWidget { + const ExampleVerticalDouble({ + super.key, + required this.title, + // this.reverse = false, + // this.headerDirection = AxisDirection.down, + }); // : assert(axisDirectionToAxis(headerDirection) == Axis.vertical); + + final String title; + // final bool reverse; + // final AxisDirection headerDirection; + + @override + Widget build(BuildContext context) { + const centerSliverKey = ValueKey('center sliver'); + const numSections = 100; + const numBottomSections = 2; + const numPerSection = 10; + return Scaffold( + appBar: AppBar(title: Text(title)), + body: CustomScrollView( + semanticChildCount: numSections, + anchor: 0.5, + center: centerSliverKey, + slivers: [ + SliverStickyHeaderList( + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildBuilderDelegate( + childCount: numSections - numBottomSections, + (context, i) { + final ii = i + numBottomSections; + return StickyHeaderItem( + header: WideHeader(i: ii), + child: Column( + children: List.generate(numPerSection + 1, (j) { + if (j == 0) return WideHeader(i: ii); + return WideItem(i: ii, j: j-1); + }))); + })), + SliverStickyHeaderList( + key: centerSliverKey, + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildBuilderDelegate( + childCount: numBottomSections, + (context, i) { + final ii = numBottomSections - 1 - i; + return StickyHeaderItem( + header: WideHeader(i: ii), + child: Column( + children: List.generate(numPerSection + 1, (j) { + if (j == 0) return WideHeader(i: ii); + return WideItem(i: ii, j: j-1); + }))); + })), + ])); + } +} + //////////////////////////////////////////////////////////////////////////// // // That's it! @@ -257,6 +316,11 @@ class MainPage extends StatelessWidget { reverse: true, headerDirection: AxisDirection.left), ]; + final otherItems = [ + _buildButton(context, + title: 'Double slivers', + page: ExampleVerticalDouble(title: 'Double slivers')), + ]; return Scaffold( appBar: AppBar(title: const Text('Sticky Headers example')), body: CustomScrollView(slivers: [ @@ -284,6 +348,18 @@ class MainPage extends StatelessWidget { childAspectRatio: 2, crossAxisCount: 2, children: horizontalItems)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: Center( + child: Text("Other examples", + style: Theme.of(context).textTheme.headlineMedium)))), + SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + sliver: SliverGrid.count( + childAspectRatio: 2, + crossAxisCount: 2, + children: otherItems)), ])); } @@ -304,7 +380,14 @@ class MainPage extends StatelessWidget { title: title, reverse: reverse, headerDirection: headerDirection); break; } + return _buildButton(context, title: title, page: page); + } + Widget _buildButton(BuildContext context, { + bool primary = false, + required String title, + required Widget page, + }) { var label = Text(title, textAlign: TextAlign.center, style: TextStyle( From 1e8899bcb4f7c170061f066702706545176e1dbd Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 18 Jan 2024 11:18:17 -0500 Subject: [PATCH 013/110] sticky_header: Fix _findChildAtEnd when viewport partly consumed already In particular this will affect the upper sliver (with older messages) in the message list, when we start using two slivers there in earnest. --- lib/widgets/sticky_header.dart | 5 ++- test/widgets/sticky_header_test.dart | 64 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 9442ea04eb..b040eb78db 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -741,7 +741,10 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList { /// /// This means (child start) < (viewport end) <= (child end). RenderBox? _findChildAtEnd() { - final endOffset = constraints.scrollOffset + constraints.viewportMainAxisExtent; + /// The end of the visible area available to this sliver, + /// in this sliver's "scroll offset" coordinates. + final endOffset = constraints.scrollOffset + + constraints.remainingPaintExtent; RenderBox? child; for (child = lastChild; ; child = childBefore(child)) { diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index 611209b2f8..f46e46438d 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -153,6 +153,70 @@ void main() { // will get placed at zero rather than properly extend up off screen. check(tester.getTopLeft(find.text("Item 0"))).equals(Offset(0, -200)); }); + + testWidgets('sliver only part of viewport, header at end', (tester) async { + const centerKey = ValueKey('center'); + final controller = ScrollController(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: CustomScrollView( + controller: controller, + anchor: 0.5, + center: centerKey, + slivers: [ + SliverStickyHeaderList( + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildListDelegate( + List.generate(100, (i) => StickyHeaderItem( + header: _Header(99 - i, height: 20), + child: _Item(99 - i, height: 100))))), + SliverStickyHeaderList( + key: centerKey, + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildListDelegate( + List.generate(100, (i) => StickyHeaderItem( + header: _Header(100 + i, height: 20), + child: _Item(100 + i, height: 100))))), + ]))); + + final overallSize = tester.getSize(find.byType(CustomScrollView)); + final extent = overallSize.onAxis(Axis.vertical); + assert(extent == 600); + + void checkState(int index, {required double item, required double header}) { + final itemElement = tester.firstElement(find.byElementPredicate((element) { + if (element.widget is! _Item) return false; + final renderObject = element.renderObject as RenderBox; + return (renderObject.size.contains(renderObject.globalToLocal( + Offset(overallSize.width / 2, 1) + ))); + })); + final itemWidget = itemElement.widget as _Item; + check(itemWidget.index).equals(index); + // TODO the `.first` calls should be unnecessary; that's another bug + // check(_headerIndex(tester)).equals(index); + check(tester.widget<_Header>(find.byType(_Header).first).index) + .equals(index); + check((itemElement.renderObject as RenderBox).localToGlobal(Offset(0, 0))) + .equals(Offset(0, item)); + check(tester.getTopLeft(find.byType(_Header).first)) + .equals(Offset(0, header)); + } + + check(controller.offset).equals(0); + checkState( 97, item: 0, header: 0); + + controller.jumpTo(-5); + await tester.pump(); + checkState( 96, item: -95, header: -15); + + controller.jumpTo(-600); + await tester.pump(); + checkState( 91, item: 0, header: 0); + + controller.jumpTo(600); + await tester.pump(); + checkState(103, item: 0, header: 0); + }); } Future _checkSequence( From 5d5814358dd603c932a5e59bb495fa9ae9dd8150 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 25 Jan 2024 21:24:24 -0800 Subject: [PATCH 014/110] sticky_header: Avoid header at sliver/sliver boundary --- lib/widgets/sticky_header.dart | 17 +++++++++++++++-- test/widgets/sticky_header_test.dart | 8 ++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index b040eb78db..24eeb8d518 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -774,10 +774,23 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList { final RenderBox? child; switch (widget.headerPlacement._byGrowth(constraints.growthDirection)) { + case _HeaderGrowthPlacement.growthStart: + if (constraints.remainingPaintExtent < constraints.viewportMainAxisExtent) { + // Part of the viewport is occupied already by other slivers. The way + // a RenderViewport does layout means that the already-occupied part is + // the part that's before this sliver in the growth direction. + // Which means that's the place where the header would go. + child = null; + } else { + child = _findChildAtStart(); + } case _HeaderGrowthPlacement.growthEnd: + // The edge this sliver wants to place a header at is the one where + // this sliver is free to run all the way to the viewport's edge; any + // further slivers in that direction will be laid out after this one. + // So if this sliver placed a child there, it's at the edge of the + // whole viewport and should determine a header. child = _findChildAtEnd(); - case _HeaderGrowthPlacement.growthStart: - child = _findChildAtStart(); } (parent! as _RenderSliverStickyHeaderList)._rebuildHeader(child); diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index f46e46438d..c283652ae1 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -192,14 +192,10 @@ void main() { })); final itemWidget = itemElement.widget as _Item; check(itemWidget.index).equals(index); - // TODO the `.first` calls should be unnecessary; that's another bug - // check(_headerIndex(tester)).equals(index); - check(tester.widget<_Header>(find.byType(_Header).first).index) - .equals(index); + check(_headerIndex(tester)).equals(index); check((itemElement.renderObject as RenderBox).localToGlobal(Offset(0, 0))) .equals(Offset(0, item)); - check(tester.getTopLeft(find.byType(_Header).first)) - .equals(Offset(0, header)); + check(tester.getTopLeft(find.byType(_Header))).equals(Offset(0, header)); } check(controller.offset).equals(0); From 52118ab2c683daf35d653f373ca97ccffc08c3e1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 4 Dec 2024 18:01:42 -0800 Subject: [PATCH 015/110] icons: Add resolve/unresolve-topic icons, from Figma Taken from Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2064-417365&m=dev https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6329-127618&m=dev --- assets/icons/ZulipIcons.ttf | Bin 12136 -> 12464 bytes assets/icons/check.svg | 3 ++ assets/icons/check_remove.svg | 4 +++ lib/widgets/icons.dart | 66 ++++++++++++++++++---------------- 4 files changed, 43 insertions(+), 30 deletions(-) create mode 100644 assets/icons/check.svg create mode 100644 assets/icons/check_remove.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 15fce5b25a31060652d65f3290a9436f48f6d7f5..0be670d5cb409c5a8b45cecef5c85e0fedfded51 100644 GIT binary patch delta 1966 zcmZ`)O>7%Q6n?Y2UOV>MX%p9RlhU|O?u~v$B&|a0{c^Qb^JO+3 z5SgB0ZD(q>K6&QdAO8lyERpBQbfq-0_-OMEbbki4X)IWnRK)x)aD2MDu=MHtKhT$8 z&uDh8oGVQ}I*(23HWaQ`OG`C&mL0*S8}FV=rE2BSMo}dSPY?;|+T4|e%~S0ch@z`_ z@3?Wd&tB9sKo;|3<;OR660F!f%BC7m$xa@mb&Gyt#8fuMHib8Y4~13X3t@{SvXYmw zG(rNB58w&X0EHl<6r(sLpkc$9AU_S_Jw!?Dq)3A^>;Lq_x?KdtGeb8-s%{@4)&Ri~ zgrI>StP^V>(y%=QMO6==n|zqLU>bo7O?RH4PBKvjRxzwNF^^ChP6~7gHW7poM94C^ z=>S>uu=8LH!AFMLp`F5h8kP;Nbd(Mv3qPFj45E5ADab+Uptfi0`!RD15A~4(?Eok= z*bHF|z&;4q$3Y*&3b&WZf#9?B3Z15Ns3fnIADa7hN;O~@re3&->l>Ub3Ud`fnVzkA zFwbC}7xpFOr@>8_j$ziKGb76|oSdaenx|`Ev%^+{W8Sa`IR9&yMGu@;`2^M*JcA6B zvXC~EimSJ}$;&U}@nfH7=SKs0NG($<=DdiUMnNDv<6*tz9^|axJpemDxI&bpJl+$q zw1Y1QJ8n6^wHxSA7u@>cHU_m;KixVZr)HR$aBR4_2t6;}Dt;8a7K7n8RkOoyS( zYm=uQ%<{TR-YzHgV~il6)|wByDl`Lhj3Fl8k^S?mBBLbYOY6##FxFg>3e4BskTSA( zA7A_Z=9Uy_x=i22w)HQH=piEGw?i1xicfMI?BC$sig*P)<8+bgyS}DF?n{K=EDaZ7 zcu(q|P!w6F;4Fe1hfpehj1q`5ivksBxOqW7W$wozA|^Kz=28zB(t}|w8H>X*m5Bt0 zasf4w&khuZvA@z>F_{|*ypt&L5x)X3ZXg6%GT?{A6`>;ySvD{TiEBhh7_wqOgPb&w zgq$)EgPb-HhrHO}0}usf44i<(<)lnL0&eHgPb=IfV^S= zcONYn$R#kjY9J4}Xdnyufq?~qB{cH(s}r(ntJ#?o#5dyORUIFHm%Wgr>Q($DbFbSI6hyg&Mj8fPe!}g!5OxQM%3A5 Jc9rpV(!cnP9}@ro delta 1644 zcmb7EOKe+36g~6&iT@3yrD>Z6lf>U=`}yrDl8Mo!!6xqVEq}|FOI> zS8Kd-``j(S^C1;m&BjJcObZ4R16&8MG@1*?KOfHmx(R5-*2>lP>Mw~EAh}GNKX2}g z`0G{$tfKVzvokjjGNRrc5{sSh;YW~VeS{wcL{`j*U2RyK)Yh~u?H=s#U>FrlLSyk^ zei1|wqf8))6bxE;IT{Ed!*vW7&y=!jzpDKmaa!L{MeK z7&!`Lb1=mS?M-x~(GZTXiV*2!1qrK~9AyUmaQ~}6%vpK_@f>`#4l{&FpEAcV{Uf9v zX7~s-(%(t?F#9)w^LUkBvR5H$4*Qhr&@qlPq)AyDGFXD{X$&&Vp>Bxt0`;=7r&*s# znmC^2tQXF~WTK=wiap|D-D98?NDpa7GD4}?>+ z#C@LLtnH}ny6tDX&vDDK?;Ll&-*=<$z%}jKbEn;JyT9~QJ$v4D@5jElZ_6L>uler; z-Uxhsq}}iP{~x)xo8GT%+kyU1hxqD#k$SURH;UUgj1!`NU0h~Hn_^X56EgpQ0C?Ba AEdT%j diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 0000000000..26332a3599 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/check_remove.svg b/assets/icons/check_remove.svg new file mode 100644 index 0000000000..cc5939b04d --- /dev/null +++ b/assets/icons/check_remove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 7d58305fb4..82cb83704b 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -42,95 +42,101 @@ abstract final class ZulipIcons { /// The Zulip custom icon "camera". static const IconData camera = IconData(0xf106, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check". + static const IconData check = IconData(0xf107, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check_remove". + static const IconData check_remove = IconData(0xf108, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf107, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf109, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf108, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf126, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From 2412fbcb22354c9382fb9590a6b260ff3ad98c34 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 28 Jan 2025 16:17:46 -0800 Subject: [PATCH 016/110] action_sheet: Add and use PageRoot --- lib/widgets/action_sheet.dart | 32 +++++++++++++++++++------------- lib/widgets/message_list.dart | 6 ++++-- lib/widgets/page.dart | 29 ++++++++++++++++++++++++++++- test/widgets/test_app.dart | 7 ++++--- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 3eff182bca..a689d86550 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -24,6 +24,7 @@ import 'emoji_reaction.dart'; import 'icons.dart'; import 'inset_shadow.dart'; import 'message_list.dart'; +import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -163,11 +164,15 @@ class ActionSheetCancelButton extends StatelessWidget { } /// Show a sheet of actions you can take on a topic. +/// +/// Needs a [PageRoot] ancestor. void showTopicActionSheet(BuildContext context, { required int channelId, required TopicName topic, }) { - final store = PerAccountStoreWidget.of(context); + final pageContext = PageRoot.contextOf(context); + + final store = PerAccountStoreWidget.of(pageContext); final subscription = store.subscriptions[channelId]; final optionButtons = []; @@ -237,7 +242,7 @@ void showTopicActionSheet(BuildContext context, { currentVisibilityPolicy: visibilityPolicy, newVisibilityPolicy: to, narrow: TopicNarrow(channelId, topic), - pageContext: context); + pageContext: pageContext); })); if (optionButtons.isEmpty) { @@ -250,7 +255,7 @@ void showTopicActionSheet(BuildContext context, { return; } - _showActionSheet(context, optionButtons: optionButtons); + _showActionSheet(pageContext, optionButtons: optionButtons); } class UserTopicUpdateButton extends ActionSheetMenuItemButton { @@ -376,14 +381,15 @@ class UserTopicUpdateButton extends ActionSheetMenuItemButton { /// /// Must have a [MessageListPage] ancestor. void showMessageActionSheet({required BuildContext context, required Message message}) { - final store = PerAccountStoreWidget.of(context); + final pageContext = PageRoot.contextOf(context); + final store = PerAccountStoreWidget.of(pageContext); // The UI that's conditioned on this won't live-update during this appearance // of the action sheet (we avoid calling composeBoxControllerOf in a build // method; see its doc). // So we rely on the fact that isComposeBoxOffered for any given message list // will be constant through the page's life. - final messageListPage = MessageListPage.ancestorOf(context); + final messageListPage = MessageListPage.ancestorOf(pageContext); final isComposeBoxOffered = messageListPage.composeBoxController != null; final isMessageRead = message.flags.contains(MessageFlag.read); @@ -391,18 +397,18 @@ void showMessageActionSheet({required BuildContext context, required Message mes final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; final optionButtons = [ - ReactionButtons(message: message, pageContext: context), - StarButton(message: message, pageContext: context), + ReactionButtons(message: message, pageContext: pageContext), + StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) - QuoteAndReplyButton(message: message, pageContext: context), + QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) - MarkAsUnreadButton(message: message, pageContext: context), - CopyMessageTextButton(message: message, pageContext: context), - CopyMessageLinkButton(message: message, pageContext: context), - ShareButton(message: message, pageContext: context), + MarkAsUnreadButton(message: message, pageContext: pageContext), + CopyMessageTextButton(message: message, pageContext: pageContext), + CopyMessageLinkButton(message: message, pageContext: pageContext), + ShareButton(message: message, pageContext: pageContext), ]; - _showActionSheet(context, optionButtons: optionButtons); + _showActionSheet(pageContext, optionButtons: optionButtons); } abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton { diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index a27a8051e9..c28116ee15 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -277,7 +277,9 @@ class _MessageListPageState extends State implements MessageLis narrow: ChannelNarrow(streamId))))); } - return Scaffold( + // Insert a PageRoot here, to provide a context that can be used for + // MessageListPage.ancestorOf. + return PageRoot(child: Scaffold( appBar: ZulipAppBar( buildTitle: (willCenterTitle) => MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle), @@ -318,7 +320,7 @@ class _MessageListPageState extends State implements MessageLis ))), if (ComposeBox.hasComposeBox(narrow)) ComposeBox(key: _composeBoxKey, narrow: narrow) - ]))); + ])))); } } diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index bebb37c22c..0ba65fb3d4 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -3,6 +3,30 @@ import 'package:flutter/material.dart'; import 'store.dart'; +/// An [InheritedWidget] for near the root of a page's widget subtree, +/// providing its [BuildContext]. +/// +/// Useful when needing a context that persists through the page's lifespan, +/// e.g. for a show-action-sheet function +/// whose buttons use a context to close the sheet +/// or show an error dialog / snackbar asynchronously. +/// +/// (In this scenario, it would be buggy to use the context of the element +/// that was long-pressed, +/// if the element can unmount as part of handling a Zulip event.) +class PageRoot extends InheritedWidget { + const PageRoot({super.key, required super.child}); + + @override + bool updateShouldNotify(covariant PageRoot oldWidget) => false; + + static BuildContext contextOf(BuildContext context) { + final element = context.getElementForInheritedWidgetOfExactType(); + assert(element != null, 'No PageRoot ancestor'); + return element!; + } +} + /// A page route that always builds the same widget. /// /// This is useful for making the route more transparent for a test to inspect. @@ -42,7 +66,10 @@ mixin AccountPageRouteMixin on PageRoute { accountId: accountId, placeholder: loadingPlaceholderPage ?? const LoadingPlaceholderPage(), routeToRemoveOnLogout: this, - child: super.buildPage(context, animation, secondaryAnimation)); + // PageRoot goes under PerAccountStoreWidget, so the provided context + // can be used for PerAccountStoreWidget.of. + child: PageRoot( + child: super.buildPage(context, animation, secondaryAnimation))); } } diff --git a/test/widgets/test_app.dart b/test/widgets/test_app.dart index 4c76fbb2d8..e431aeaf23 100644 --- a/test/widgets/test_app.dart +++ b/test/widgets/test_app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:zulip/generated/l10n/zulip_localizations.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/theme.dart'; @@ -77,9 +78,9 @@ class TestZulipApp extends StatelessWidget { navigatorObservers: navigatorObservers ?? const [], home: accountId != null - ? PerAccountStoreWidget(accountId: accountId!, child: child) - : child, - ); + ? PerAccountStoreWidget(accountId: accountId!, + child: PageRoot(child: child)) + : PageRoot(child: child)); })); } } From 731b44f8abb8ff83cbd9e0deaa4fc5dc36dee3c1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 4 Dec 2024 16:09:27 -0800 Subject: [PATCH 017/110] action_sheet: Implement resolve/unresolve in topic action sheet Fixes: #744 --- assets/l10n/app_en.arb | 16 +++ lib/api/model/model.dart | 6 + lib/generated/l10n/zulip_localizations.dart | 24 ++++ .../l10n/zulip_localizations_ar.dart | 12 ++ .../l10n/zulip_localizations_en.dart | 12 ++ .../l10n/zulip_localizations_ja.dart | 12 ++ .../l10n/zulip_localizations_nb.dart | 12 ++ .../l10n/zulip_localizations_pl.dart | 12 ++ .../l10n/zulip_localizations_ru.dart | 12 ++ .../l10n/zulip_localizations_sk.dart | 12 ++ lib/widgets/action_sheet.dart | 84 ++++++++++++ lib/widgets/inbox.dart | 7 +- lib/widgets/message_list.dart | 20 ++- test/widgets/action_sheet_test.dart | 128 +++++++++++++++++- 14 files changed, 359 insertions(+), 10 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ee7e96c35f..ba02a819b1 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -92,6 +92,22 @@ "@actionSheetOptionUnfollowTopic": { "description": "Label for unfollowing a topic on action sheet." }, + "actionSheetOptionResolveTopic": "Mark as resolved", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "actionSheetOptionUnresolveTopic": "Mark as unresolved", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Failed to mark topic as resolved", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "Failed to mark topic as unresolved", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, "actionSheetOptionCopyMessageText": "Copy message text", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 03af104baf..fad8ddc5bc 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -695,6 +695,12 @@ extension type const TopicName(String _value) { /// The key to use for "same topic as" comparisons. String canonicalize() => apiName.toLowerCase(); + /// Whether the topic starts with [resolvedTopicPrefix]. + bool get isResolved => _value.startsWith(resolvedTopicPrefix); + + /// This [TopicName] plus the [resolvedTopicPrefix] prefix. + TopicName resolve() => TopicName(resolvedTopicPrefix + _value); + /// A [TopicName] with [resolvedTopicPrefixRegexp] stripped if present. TopicName unresolve() => TopicName(_value.replaceFirst(resolvedTopicPrefixRegexp, '')); diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b6fbb70769..9be039ef72 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -243,6 +243,30 @@ abstract class ZulipLocalizations { /// **'Unfollow topic'** String get actionSheetOptionUnfollowTopic; + /// Label for the 'Mark as resolved' button on the topic action sheet. + /// + /// In en, this message translates to: + /// **'Mark as resolved'** + String get actionSheetOptionResolveTopic; + + /// Label for the 'Mark as unresolved' button on the topic action sheet. + /// + /// In en, this message translates to: + /// **'Mark as unresolved'** + String get actionSheetOptionUnresolveTopic; + + /// Error title when marking a topic as resolved failed. + /// + /// In en, this message translates to: + /// **'Failed to mark topic as resolved'** + String get errorResolveTopicFailedTitle; + + /// Error title when marking a topic as unresolved failed. + /// + /// In en, this message translates to: + /// **'Failed to mark topic as unresolved'** + String get errorUnresolveTopicFailedTitle; + /// Label for copy message text button on action sheet. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 025b4b1444..2e59dae8af 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -79,6 +79,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 9467d33428..a0e86b7d35 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -79,6 +79,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index f363ee0043..6e7f1559ac 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -79,6 +79,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 35b3e86fe5..cabd4897e9 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -79,6 +79,18 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0594722d31..0a2a2bf1d7 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -79,6 +79,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Nie śledź wątku'; + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override String get actionSheetOptionCopyMessageText => 'Skopiuj tekst wiadomości'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 879559fed4..99bd72c62f 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -79,6 +79,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Не отслеживать тему'; + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override String get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index af87dfd949..13646eafd5 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -79,6 +79,18 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnfollowTopic => 'Prestať sledovať tému'; + @override + String get actionSheetOptionResolveTopic => 'Mark as resolved'; + + @override + String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + + @override + String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + + @override + String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override String get actionSheetOptionCopyMessageText => 'Skopírovať text správy'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index a689d86550..7c3ea6e622 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -166,9 +166,13 @@ class ActionSheetCancelButton extends StatelessWidget { /// Show a sheet of actions you can take on a topic. /// /// Needs a [PageRoot] ancestor. +/// +/// The API request for resolving/unresolving a topic needs a message ID. +/// If [someMessageIdInTopic] is null, the button for that will be absent. void showTopicActionSheet(BuildContext context, { required int channelId, required TopicName topic, + required int? someMessageIdInTopic, }) { final pageContext = PageRoot.contextOf(context); @@ -245,6 +249,12 @@ void showTopicActionSheet(BuildContext context, { pageContext: pageContext); })); + if (someMessageIdInTopic != null) { + optionButtons.add(ResolveUnresolveButton(pageContext: pageContext, + topic: topic, + someMessageIdInTopic: someMessageIdInTopic)); + } + if (optionButtons.isEmpty) { // TODO(a11y): This case makes a no-op gesture handler; as a consequence, // we're presenting some UI (to people who use screen-reader software) as @@ -377,6 +387,80 @@ class UserTopicUpdateButton extends ActionSheetMenuItemButton { } } +class ResolveUnresolveButton extends ActionSheetMenuItemButton { + ResolveUnresolveButton({ + super.key, + required this.topic, + required this.someMessageIdInTopic, + required super.pageContext, + }) : _actionIsResolve = !topic.isResolved; + + /// The topic that the action sheet was opened for. + /// + /// There might not currently be any messages with this topic; + /// see dartdoc of [ActionSheetMenuItemButton]. + final TopicName topic; + + /// The message ID that was passed when opening the action sheet. + /// + /// The message with this ID might currently not exist, + /// or might exist with a different topic; + /// see dartdoc of [ActionSheetMenuItemButton]. + final int someMessageIdInTopic; + + final bool _actionIsResolve; + + @override + IconData get icon => _actionIsResolve ? ZulipIcons.check : ZulipIcons.check_remove; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return _actionIsResolve + ? zulipLocalizations.actionSheetOptionResolveTopic + : zulipLocalizations.actionSheetOptionUnresolveTopic; + } + + @override void onPressed() async { + final zulipLocalizations = ZulipLocalizations.of(pageContext); + final store = PerAccountStoreWidget.of(pageContext); + + // We *could* check here if the topic has changed since the action sheet was + // opened (see dartdoc of [ActionSheetMenuItemButton]) and abort if so. + // We simplify by not doing so. + // There's already an inherent race that that check wouldn't help with: + // when you tap the button, an intervening topic change may already have + // happened, just not reached us in an event yet. + // Discussion, including about what web does: + // https://github.com/zulip/zulip-flutter/pull/1301#discussion_r1936181560 + + try { + await updateMessage(store.connection, + messageId: someMessageIdInTopic, + topic: _actionIsResolve ? topic.resolve() : topic.unresolve(), + propagateMode: PropagateMode.changeAll, + sendNotificationToOldThread: false, + sendNotificationToNewThread: true, + ); + } catch (e) { + if (!pageContext.mounted) return; + + String? errorMessage; + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO(#741) specific messages for common errors, like network errors + // (support with reusable code) + default: + } + + final title = _actionIsResolve + ? zulipLocalizations.errorResolveTopicFailedTitle + : zulipLocalizations.errorUnresolveTopicFailedTitle; + showErrorDialog(context: pageContext, title: title, message: errorMessage); + } + } +} + /// Show a sheet of actions you can take on a message in the message list. /// /// Must have a [MessageListPage] ancestor. diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 6dbe31ce04..799f763f1c 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -507,7 +507,8 @@ class _TopicItem extends StatelessWidget { @override Widget build(BuildContext context) { - final _StreamSectionTopicData(:topic, :count, :hasMention) = data; + final _StreamSectionTopicData( + :topic, :count, :hasMention, :lastUnreadId) = data; final store = PerAccountStoreWidget.of(context); final subscription = store.subscriptions[streamId]!; @@ -525,7 +526,9 @@ class _TopicItem extends StatelessWidget { MessageListPage.buildRoute(context: context, narrow: narrow)); }, onLongPress: () => showTopicActionSheet(context, - channelId: streamId, topic: topic), + channelId: streamId, + topic: topic, + someMessageIdInTopic: lastUnreadId), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(width: 63), diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index c28116ee15..9f21a19fb0 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -404,8 +404,20 @@ class MessageListAppBarTitle extends StatelessWidget { width: double.infinity, child: GestureDetector( behavior: HitTestBehavior.translucent, - onLongPress: () => showTopicActionSheet(context, - channelId: streamId, topic: topic), + onLongPress: () { + final someMessage = MessageListPage.ancestorOf(context) + .model?.messages.firstOrNull; + // If someMessage is null, the topic action sheet won't have a + // resolve/unresolve button. That seems OK; in that case we're + // either still fetching messages (and the user can reopen the + // sheet after that finishes) or there aren't any messages to + // act on anyway. + assert(someMessage == null || narrow.containsMessage(someMessage)); + showTopicActionSheet(context, + channelId: streamId, + topic: topic, + someMessageIdInTopic: someMessage?.id); + }, child: Column( crossAxisAlignment: willCenterTitle ? CrossAxisAlignment.center : CrossAxisAlignment.start, @@ -1127,7 +1139,9 @@ class StreamMessageRecipientHeader extends StatelessWidget { MessageListPage.buildRoute(context: context, narrow: TopicNarrow.ofMessage(message))), onLongPress: () => showTopicActionSheet(context, - channelId: message.streamId, topic: topic), + channelId: message.streamId, + topic: topic, + someMessageIdInTopic: message.id), child: ColoredBox( color: backgroundColor, child: Row( diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 2bb07b4946..7da94cfd36 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -227,9 +227,10 @@ void main() { } checkButton('Follow topic'); + checkButton('Mark as resolved'); } - testWidgets('show from inbox', (tester) async { + testWidgets('show from inbox; message in Unreads but not in MessageStore', (tester) async { await prepare(unreadMsgs: eg.unreadMsgs(count: 1, channels: [eg.unreadChannelMsgs( streamId: someChannel.streamId, @@ -237,6 +238,17 @@ void main() { unreadMessageIds: [someMessage.id], )])); await showFromInbox(tester); + check(store.unreads.isUnread(someMessage.id)).isNotNull().isTrue(); + check(store.messages).not((it) => it.containsKey(someMessage.id)); + checkButtons(); + }); + + testWidgets('show from inbox; message in Unreads and in MessageStore', (tester) async { + await prepare(); + await store.addMessage(someMessage); + await showFromInbox(tester); + check(store.unreads.isUnread(someMessage.id)).isNotNull().isTrue(); + check(store.messages)[someMessage.id].isNotNull(); checkButtons(); }); @@ -246,6 +258,13 @@ void main() { checkButtons(); }); + testWidgets('show from app bar: resolve/unresolve not offered when msglist empty', (tester) async { + await prepare(); + await showFromAppBar(tester, messages: []); + check(findButtonForLabel('Mark as resolved')).findsNothing(); + check(findButtonForLabel('Mark as unresolved')).findsNothing(); + }); + testWidgets('show from recipient header', (tester) async { await prepare(); await showFromRecipientHeader(tester); @@ -289,10 +308,6 @@ void main() { } void checkButtons(List expectedButtonFinders) { - if (expectedButtonFinders.isEmpty) { - check(actionSheetFinder).findsNothing(); - return; - } check(actionSheetFinder).findsOne(); for (final buttonFinder in expectedButtonFinders) { @@ -450,6 +465,109 @@ void main() { } }); }); + + group('ResolveUnresolveButton', () { + void checkRequest(int messageId, String topic) { + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'topic': topic, + 'propagate_mode': 'change_all', + 'send_notification_to_old_thread': 'false', + 'send_notification_to_new_thread': 'true', + }); + } + + testWidgets('resolve: happy path from inbox; message in Unreads but not MessageStore', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: 'zulip'); + await prepare( + topic: 'zulip', + unreadMsgs: eg.unreadMsgs(count: 1, + channels: [eg.unreadChannelMsgs( + streamId: someChannel.streamId, + topic: 'zulip', + unreadMessageIds: [message.id], + )])); + await showFromInbox(tester, topic: 'zulip'); + check(store.messages).not((it) => it.containsKey(message.id)); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as resolved')); + await tester.pumpAndSettle(); + + checkNoErrorDialog(tester); + checkRequest(message.id, '✔ zulip'); + }); + + testWidgets('resolve: happy path from inbox; message in Unreads and MessageStore', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: 'zulip'); + await prepare(topic: 'zulip'); + await store.addMessage(message); + await showFromInbox(tester, topic: 'zulip'); + check(store.unreads.isUnread(message.id)).isNotNull().isTrue(); + check(store.messages)[message.id].isNotNull(); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as resolved')); + await tester.pumpAndSettle(); + + checkNoErrorDialog(tester); + checkRequest(message.id, '✔ zulip'); + }); + + testWidgets('unresolve: happy path', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: '✔ zulip'); + await prepare(topic: '✔ zulip'); + await showFromAppBar(tester, topic: '✔ zulip', messages: [message]); + connection.takeRequests(); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as unresolved')); + await tester.pumpAndSettle(); + + checkNoErrorDialog(tester); + checkRequest(message.id, 'zulip'); + }); + + testWidgets('unresolve: weird prefix', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: '✔ ✔ zulip'); + await prepare(topic: '✔ ✔ zulip'); + await showFromAppBar(tester, topic: '✔ ✔ zulip', messages: [message]); + connection.takeRequests(); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(findButtonForLabel('Mark as unresolved')); + await tester.pumpAndSettle(); + + checkNoErrorDialog(tester); + checkRequest(message.id, 'zulip'); + }); + + testWidgets('resolve: request fails', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: 'zulip'); + await prepare(topic: 'zulip'); + await showFromRecipientHeader(tester, message: message); + connection.takeRequests(); + connection.prepare(exception: http.ClientException('Oops')); + await tester.tap(findButtonForLabel('Mark as resolved')); + await tester.pumpAndSettle(); + checkRequest(message.id, '✔ zulip'); + + checkErrorDialog(tester, + expectedTitle: 'Failed to mark topic as resolved'); + }); + + testWidgets('unresolve: request fails', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: '✔ zulip'); + await prepare(topic: '✔ zulip'); + await showFromRecipientHeader(tester, message: message); + connection.takeRequests(); + connection.prepare(exception: http.ClientException('Oops')); + await tester.tap(findButtonForLabel('Mark as unresolved')); + await tester.pumpAndSettle(); + checkRequest(message.id, 'zulip'); + + checkErrorDialog(tester, + expectedTitle: 'Failed to mark topic as unresolved'); + }); + }); }); group('message action sheet', () { From 0e6cacb25c339957e8ed7a64deadfb4c1eeacf06 Mon Sep 17 00:00:00 2001 From: E-m-i-n-e-n-c-e Date: Thu, 30 Jan 2025 15:15:52 +0530 Subject: [PATCH 018/110] msglist: Fix DM header color lerp bug. Fixed a bug in MessageListTheme.lerp() where dmRecipientHeaderBg was using streamMessageBgDefault instead of dmRecipientHeaderBg for interpolation. --- lib/widgets/message_list.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 9f21a19fb0..4263041dbc 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -153,7 +153,7 @@ class MessageListTheme extends ThemeExtension { return MessageListTheme._( dateSeparator: Color.lerp(dateSeparator, other.dateSeparator, t)!, dateSeparatorText: Color.lerp(dateSeparatorText, other.dateSeparatorText, t)!, - dmRecipientHeaderBg: Color.lerp(streamMessageBgDefault, other.dmRecipientHeaderBg, t)!, + dmRecipientHeaderBg: Color.lerp(dmRecipientHeaderBg, other.dmRecipientHeaderBg, t)!, messageTimestamp: Color.lerp(messageTimestamp, other.messageTimestamp, t)!, recipientHeaderText: Color.lerp(recipientHeaderText, other.recipientHeaderText, t)!, senderBotIcon: Color.lerp(senderBotIcon, other.senderBotIcon, t)!, From bc0dfdeab4d449a0ac78a9b61cfb0ca9e0c76b4d Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 7 Feb 2025 13:46:21 -0800 Subject: [PATCH 019/110] changelog: Describe changes since 0.0.25 --- docs/changelog.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index fcbded741d..1de438997b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,22 @@ ## Unreleased +### Highlights for users + +* Resolve or unresolve a topic, from the menu after you + press and hold the topic. (#744) +* Autocomplete now offers `@all`, `@topic`, and other + wildcards. (#234) +* Channel names starting with emoji go at the start of the + list. (#1202) +* Too many other improvements and fixes to describe them all here. + + +### Highlights for developers + +* Resolved: #1205, #1289, #942, #1238, #1202, #1219, #1204, #1171, + PR #1296, #234, #1207, #1330, #1309, #725, #744 + ## 0.0.25 (2025-01-13) From ebd7a99efa0058b1a9ae3c795184623d6fe240be Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 7 Feb 2025 13:46:43 -0800 Subject: [PATCH 020/110] version: Bump version to 0.0.26 --- docs/changelog.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 1de438997b..e9e4caadd5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,9 @@ ## Unreleased + +## 0.0.26 (2025-02-07) + ### Highlights for users * Resolve or unresolve a topic, from the menu after you diff --git a/pubspec.yaml b/pubspec.yaml index 27b7519334..cc1d93cca8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ description: A Zulip client for Android and iOS publish_to: 'none' # Keep the last two numbers equal; see docs/release.md. -version: 0.0.25+25 +version: 0.0.26+26 environment: # We use a recent version of Flutter from its main channel, and From c4be9eabe37b33d35aabe444d169ce59a10de8ff Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 7 Feb 2025 17:09:52 -0800 Subject: [PATCH 021/110] doc: Before release, update translations I forgot to do this before today's v0.0.26, oops. The process is only semi-automated at this point (see #276). It was fresher in mind as of the last couple of releases, but I didn't think of it today. That's what a checklist is for; add it there. --- docs/release.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release.md b/docs/release.md index cbf0020223..7895ba50b0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,6 +6,9 @@ Flutter and packages dependencies, do that first. For details of how, see our README. +* Update translations from Weblate. + See `git log --stat --grep eblate` for previous examples. + * Write an entry in `docs/changelog.md`, under "Unreleased". Commit that change. From 812d95c46b8eeb2eedeb6c24bb004952bebd379c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 7 Feb 2025 17:15:08 -0800 Subject: [PATCH 022/110] l10n: Update translations from Weblate This update followed a more boring normal process again, the same as c9c5f3d4d. Unlike 833b5fda1, no manual adjustment needed; the workaround that the adjustment in that commit was part of seems to have succeeded in getting Weblate to handle `nb` appropriately. --- assets/l10n/app_nb.arb | 15 +- assets/l10n/app_pl.arb | 132 +++++++++++++++++- assets/l10n/app_ru.arb | 4 - .../l10n/zulip_localizations_nb.dart | 6 +- .../l10n/zulip_localizations_pl.dart | 50 +++---- 5 files changed, 170 insertions(+), 37 deletions(-) diff --git a/assets/l10n/app_nb.arb b/assets/l10n/app_nb.arb index 0967ef424b..fb72c02a9d 100644 --- a/assets/l10n/app_nb.arb +++ b/assets/l10n/app_nb.arb @@ -1 +1,14 @@ -{} +{ + "aboutPageAppVersion": "App versjon", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageTitle": "Om Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "aboutPageOpenSourceLicenses": "Lisenser for åpen kildekode", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + } +} diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index ba918083d1..e51a31771d 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -277,10 +277,6 @@ } } }, - "composeBoxUnknownChannelName": "(nieznany kanał)", - "@composeBoxUnknownChannelName": { - "description": "Replacement name for channel when it cannot be found in the store." - }, "composeBoxTopicHintText": "Wątek", "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." @@ -776,5 +772,133 @@ "errorUnmuteTopicFailed": "Wznowienie bez powodzenia", "@errorUnmuteTopicFailed": { "description": "Error message when unmuting a topic failed." + }, + "wildcardMentionAll": "wszyscy", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionStream": "strumień", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "wildcardMentionEveryone": "każdy", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionChannel": "kanał", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionTopic": "wątek", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionTopicDescription": "Powiadom w wątku", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "wildcardMentionAllDmDescription": "Powiadom zainteresowanych", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "wildcardMentionStreamDescription": "Powiadom w strumieniu", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionChannelDescription": "Powiadom w kanale", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "errorCouldNotShowUserProfile": "Nie udało się wyświetlić profilu.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "errorCouldNotOpenLinkTitle": "Nie udało się otworzyć odnośnika", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorCouldNotOpenLink": "Nie można otworzyć: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "dmsWithYourselfPageTitle": "DM do siebie", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "dmsWithOthersPageTitle": "DM z {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "lightboxVideoCurrentPosition": "Obecna pozycja", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxVideoDuration": "Długość wideo", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "recentDmConversationsSectionHeader": "Wiadomości bezpośrednie", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "reactedEmojiSelfUser": "Ty", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "pollVoterNames": "({voterNames})", + "@pollVoterNames": { + "description": "The list of people who voted for a poll option, wrapped in parentheses.", + "placeholders": { + "voterNames": { + "type": "String", + "example": "Alice, Bob, Chad" + } + } + }, + "noEarlierMessages": "Brak historii", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "scrollToBottomTooltip": "Przewiń do dołu", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "composeBoxLoadingMessage": "(ładowanie wiadomości {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "pinnedSubscriptionsLabel": "Przypięte", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "subscriptionListNoChannels": "Nie odnaleziono kanałów", + "@subscriptionListNoChannels": { + "description": "Text to display on subscribed-channels page when there are no subscribed channels." + }, + "unknownChannelName": "(nieznany kanał)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "unpinnedSubscriptionsLabel": "Odpięte", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index fc7e7c964d..04e971cb4b 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -571,10 +571,6 @@ "@errorFollowTopicFailed": { "description": "Error message when following a topic failed." }, - "composeBoxUnknownChannelName": "(неизвестный канал)", - "@composeBoxUnknownChannelName": { - "description": "Replacement name for channel when it cannot be found in the store." - }, "dialogContinue": "Продолжить", "@dialogContinue": { "description": "Button label in dialogs to proceed." diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index cabd4897e9..32bea953d9 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -9,13 +9,13 @@ class ZulipLocalizationsNb extends ZulipLocalizations { ZulipLocalizationsNb([String locale = 'nb']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Om Zulip'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'App versjon'; @override - String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + String get aboutPageOpenSourceLicenses => 'Lisenser for åpen kildekode'; @override String get aboutPageTapToView => 'Tap to view'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0a2a2bf1d7..7ed4ff789d 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -53,7 +53,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get profileButtonSendDirectMessage => 'Wyślij wiadomość bezpośrednią'; @override - String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + String get errorCouldNotShowUserProfile => 'Nie udało się wyświetlić profilu.'; @override String get permissionsNeededTitle => 'Wymagane uprawnienia'; @@ -204,11 +204,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + String get errorCouldNotOpenLinkTitle => 'Nie udało się otworzyć odnośnika'; @override String errorCouldNotOpenLink(String url) { - return 'Link could not be opened: $url'; + return 'Nie można otworzyć: $url'; } @override @@ -279,7 +279,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get composeBoxSendTooltip => 'Wyślij'; @override - String get unknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(nieznany kanał)'; @override String get composeBoxTopicHintText => 'Wątek'; @@ -291,14 +291,14 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String composeBoxLoadingMessage(int messageId) { - return '(loading message $messageId)'; + return '(ładowanie wiadomości $messageId)'; } @override String get unknownUserName => '(nieznany użytkownik)'; @override - String get dmsWithYourselfPageTitle => 'DMs with yourself'; + String get dmsWithYourselfPageTitle => 'DM do siebie'; @override String messageListGroupYouAndOthers(String others) { @@ -307,7 +307,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String dmsWithOthersPageTitle(String others) { - return 'DMs with $others'; + return 'DM z $others'; } @override @@ -347,10 +347,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get lightboxCopyLinkTooltip => 'Skopiuj odnośnik'; @override - String get lightboxVideoCurrentPosition => 'Current position'; + String get lightboxVideoCurrentPosition => 'Obecna pozycja'; @override - String get lightboxVideoDuration => 'Video duration'; + String get lightboxVideoDuration => 'Długość wideo'; @override String get loginPageTitle => 'Zaloguj'; @@ -506,7 +506,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get recentDmConversationsPageTitle => 'Wiadomości bezpośrednie'; @override - String get recentDmConversationsSectionHeader => 'Direct messages'; + String get recentDmConversationsSectionHeader => 'Wiadomości bezpośrednie'; @override String get combinedFeedPageTitle => 'Mieszany widok'; @@ -538,19 +538,19 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get pinnedSubscriptionsLabel => 'Pinned'; + String get pinnedSubscriptionsLabel => 'Przypięte'; @override - String get unpinnedSubscriptionsLabel => 'Unpinned'; + String get unpinnedSubscriptionsLabel => 'Odpięte'; @override - String get subscriptionListNoChannels => 'No channels found'; + String get subscriptionListNoChannels => 'Nie odnaleziono kanałów'; @override String get notifSelfUser => 'Ty'; @override - String get reactedEmojiSelfUser => 'You'; + String get reactedEmojiSelfUser => 'Ty'; @override String onePersonTyping(String typist) { @@ -566,31 +566,31 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get manyPeopleTyping => 'Wielu ludzi coś pisze…'; @override - String get wildcardMentionAll => 'all'; + String get wildcardMentionAll => 'wszyscy'; @override - String get wildcardMentionEveryone => 'everyone'; + String get wildcardMentionEveryone => 'każdy'; @override - String get wildcardMentionChannel => 'channel'; + String get wildcardMentionChannel => 'kanał'; @override - String get wildcardMentionStream => 'stream'; + String get wildcardMentionStream => 'strumień'; @override - String get wildcardMentionTopic => 'topic'; + String get wildcardMentionTopic => 'wątek'; @override - String get wildcardMentionChannelDescription => 'Notify channel'; + String get wildcardMentionChannelDescription => 'Powiadom w kanale'; @override - String get wildcardMentionStreamDescription => 'Notify stream'; + String get wildcardMentionStreamDescription => 'Powiadom w strumieniu'; @override - String get wildcardMentionAllDmDescription => 'Notify recipients'; + String get wildcardMentionAllDmDescription => 'Powiadom zainteresowanych'; @override - String get wildcardMentionTopicDescription => 'Notify topic'; + String get wildcardMentionTopicDescription => 'Powiadom w wątku'; @override String get messageIsEditedLabel => 'ZMIENIONO'; @@ -628,8 +628,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get emojiPickerSearchEmoji => 'Szukaj emoji'; @override - String get noEarlierMessages => 'No earlier messages'; + String get noEarlierMessages => 'Brak historii'; @override - String get scrollToBottomTooltip => 'Scroll to bottom'; + String get scrollToBottomTooltip => 'Przewiń do dołu'; } From c8aa4c8a35eff6d1fcb3a1171404950b5a339bb5 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 4 Feb 2025 19:27:27 -0500 Subject: [PATCH 023/110] i18n [nfc]: Explain why we skip translating licenses Signed-off-by: Zixuan James Li --- lib/licenses.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/licenses.dart b/lib/licenses.dart index a52c3f3240..c23882bb83 100644 --- a/lib/licenses.dart +++ b/lib/licenses.dart @@ -23,6 +23,12 @@ Stream additionalLicenses() async* { rootBundle.loadString('assets/Pygments/AUTHORS.txt'), ]); + // This does not need to be translated, as it is just a small fragment + // of text surrounded by a large quantity of English text that isn't + // translated anyway. + // (And it would be logistically tricky to translate, as this code is + // called from the `main` function before the [ZulipApp] widget is built, + // let alone has updated [GlobalLocalizations].) return '$licenseFileText\n\nAUTHORS file follows:\n\n$authorsFileText'; }()); yield LicenseEntryWithLineBreaks( From 6dab833dc58255d5d0f2047e48b3651db3fb2f2c Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Feb 2025 12:27:58 -0500 Subject: [PATCH 024/110] about_zulip: Translate unknown app version placeholder Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 4 ++++ lib/generated/l10n/zulip_localizations.dart | 6 ++++++ lib/generated/l10n/zulip_localizations_ar.dart | 3 +++ lib/generated/l10n/zulip_localizations_en.dart | 3 +++ lib/generated/l10n/zulip_localizations_ja.dart | 3 +++ lib/generated/l10n/zulip_localizations_nb.dart | 3 +++ lib/generated/l10n/zulip_localizations_pl.dart | 3 +++ lib/generated/l10n/zulip_localizations_ru.dart | 3 +++ lib/generated/l10n/zulip_localizations_sk.dart | 3 +++ lib/widgets/about_zulip.dart | 3 ++- 10 files changed, 33 insertions(+), 1 deletion(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ba02a819b1..63ba6f02ad 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -808,5 +808,9 @@ "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { "description": "Tooltip for button to scroll to bottom." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 9be039ef72..c3a2f34065 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1190,6 +1190,12 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'Scroll to bottom'** String get scrollToBottomTooltip; + + /// Placeholder to show in place of the app version when it is unknown. + /// + /// In en, this message translates to: + /// **'(…)'** + String get appVersionUnknownPlaceholder; } class _ZulipLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 2e59dae8af..2ab2f86f0e 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -632,4 +632,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a0e86b7d35..4650e84227 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -632,4 +632,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 6e7f1559ac..e4b30f4ea0 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -632,4 +632,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 32bea953d9..7cbc1f26d1 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -632,4 +632,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 7ed4ff789d..b05275d3b1 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -632,4 +632,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get scrollToBottomTooltip => 'Przewiń do dołu'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 99bd72c62f..1970f7223c 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -632,4 +632,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 13646eafd5..f3b9e7c2eb 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -632,4 +632,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; } diff --git a/lib/widgets/about_zulip.dart b/lib/widgets/about_zulip.dart index d0c1c8d29e..8abfa97c49 100644 --- a/lib/widgets/about_zulip.dart +++ b/lib/widgets/about_zulip.dart @@ -43,7 +43,8 @@ class _AboutZulipPageState extends State { child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ ListTile( title: Text(zulipLocalizations.aboutPageAppVersion), - subtitle: Text(_packageInfo?.version ?? '(…)')), + subtitle: Text(_packageInfo?.version + ?? zulipLocalizations.appVersionUnknownPlaceholder)), ListTile( title: Text(zulipLocalizations.aboutPageOpenSourceLicenses), subtitle: Text(zulipLocalizations.aboutPageTapToView), From 0ac905c2bad6ea5b622889471af09acf89080e24 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Feb 2025 12:55:18 -0500 Subject: [PATCH 025/110] app: Translate (well, transliterate) app title Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 4 ++++ lib/generated/l10n/zulip_localizations.dart | 6 ++++++ lib/generated/l10n/zulip_localizations_ar.dart | 3 +++ lib/generated/l10n/zulip_localizations_en.dart | 3 +++ lib/generated/l10n/zulip_localizations_ja.dart | 3 +++ lib/generated/l10n/zulip_localizations_nb.dart | 3 +++ lib/generated/l10n/zulip_localizations_pl.dart | 3 +++ lib/generated/l10n/zulip_localizations_ru.dart | 3 +++ lib/generated/l10n/zulip_localizations_sk.dart | 3 +++ lib/widgets/app.dart | 4 +++- 10 files changed, 34 insertions(+), 1 deletion(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 63ba6f02ad..f1d992939e 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -812,5 +812,9 @@ "appVersionUnknownPlaceholder": "(…)", "@appVersionUnknownPlaceholder": { "description": "Placeholder to show in place of the app version when it is unknown." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index c3a2f34065..b09b0c1853 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1196,6 +1196,12 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'(…)'** String get appVersionUnknownPlaceholder; + + /// The name of Zulip. This should be either 'Zulip' or a transliteration. + /// + /// In en, this message translates to: + /// **'Zulip'** + String get zulipAppTitle; } class _ZulipLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 2ab2f86f0e..9dd7dcdd0c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -635,4 +635,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 4650e84227..547800440c 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -635,4 +635,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index e4b30f4ea0..7eda5b1e21 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -635,4 +635,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 7cbc1f26d1..f52d8be760 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -635,4 +635,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index b05275d3b1..7174cc56a6 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -635,4 +635,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 1970f7223c..6b8a34f65a 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -635,4 +635,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index f3b9e7c2eb..d5de3f98a2 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -635,4 +635,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 9525dffdfe..f20e9f8fcc 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -181,7 +181,9 @@ class _ZulipAppState extends State with WidgetsBindingObserver { // TODO(#524) choose initial account as last one used final initialAccountId = globalStore.accounts.firstOrNull?.id; return MaterialApp( - title: 'Zulip', + onGenerateTitle: (BuildContext context) { + return ZulipLocalizations.of(context).zulipAppTitle; + }, localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, theme: themeData, From a08dcda2fe7e6a4b4a91a12ff422443957512f5e Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Feb 2025 13:11:40 -0500 Subject: [PATCH 026/110] compose: Translate message for a filename with size The string is used at the end of the "errorFilesTooLarge" message, which includes a list of files with its size that are too large. Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 8 ++++++++ lib/generated/l10n/zulip_localizations.dart | 6 ++++++ lib/generated/l10n/zulip_localizations_ar.dart | 5 +++++ lib/generated/l10n/zulip_localizations_en.dart | 5 +++++ lib/generated/l10n/zulip_localizations_ja.dart | 5 +++++ lib/generated/l10n/zulip_localizations_nb.dart | 5 +++++ lib/generated/l10n/zulip_localizations_pl.dart | 5 +++++ lib/generated/l10n/zulip_localizations_ru.dart | 5 +++++ lib/generated/l10n/zulip_localizations_sk.dart | 5 +++++ lib/widgets/compose_box.dart | 3 ++- 10 files changed, 51 insertions(+), 1 deletion(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index f1d992939e..6e86442855 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -171,6 +171,14 @@ "filename": {"type": "String", "example": "file.txt"} } }, + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": {"type": "String", "example": "foo.txt"}, + "size": {"type": "String", "example": "20.2"} + } + }, "errorFilesTooLarge": "{num, plural, =1{File is} other{{num} files are}} larger than the server's limit of {maxFileUploadSizeMib} MiB and will not be uploaded:\n\n{listMessage}", "@errorFilesTooLarge": { "description": "Error message when attached files are too large in size.", diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b09b0c1853..bbd9f4f4c9 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -351,6 +351,12 @@ abstract class ZulipLocalizations { /// **'Failed to upload file: {filename}'** String errorFailedToUploadFileTitle(String filename); + /// The name of a file, and its size in mebibytes. + /// + /// In en, this message translates to: + /// **'{filename}: {size} MiB'** + String filenameAndSizeInMiB(String filename, String size); + /// Error message when attached files are too large in size. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 9dd7dcdd0c..df0d1087db 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -137,6 +137,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Failed to upload file: $filename'; } + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + @override String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 547800440c..6d689e01a8 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -137,6 +137,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Failed to upload file: $filename'; } + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + @override String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 7eda5b1e21..dae7fc8bd2 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -137,6 +137,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'Failed to upload file: $filename'; } + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + @override String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index f52d8be760..d5604f1be8 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -137,6 +137,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'Failed to upload file: $filename'; } + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + @override String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 7174cc56a6..87dc1ce4ba 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -137,6 +137,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Nie udało się załadować pliku: $filename'; } + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + @override String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 6b8a34f65a..9d67cff8b0 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -137,6 +137,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Не удалось загрузить файл: $filename'; } + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + @override String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index d5de3f98a2..eaa1969d42 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -137,6 +137,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Nepodarilo sa nahrať súbor: $filename'; } + @override + String filenameAndSizeInMiB(String filename, String size) { + return '$filename: $size MiB'; + } + @override String errorFilesTooLarge(int num, int maxFileUploadSizeMib, String listMessage) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 9ee87754b8..2b1756e4fe 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -709,7 +709,8 @@ Future _uploadFiles({ if (tooLargeFiles.isNotEmpty) { final listMessage = tooLargeFiles - .map((file) => '${file.filename}: ${(file.length / (1 << 20)).toStringAsFixed(1)} MiB') + .map((file) => zulipLocalizations.filenameAndSizeInMiB( + file.filename, (file.length / (1 << 20)).toStringAsFixed(1))) .join('\n'); showErrorDialog( context: context, From 9ba7faf027d259901f4c34a4b469423f0fd7aa8c Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Feb 2025 13:17:08 -0500 Subject: [PATCH 027/110] i18n [nfc]: Improve errorFilesTooLarge example for listMessage The message, when used in lib/widgets/compose_box.dart, substitutes `listMessage` with newline separated lines of filenames with size. Update the example to match this usage. Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 6e86442855..87edb26a0b 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -185,7 +185,7 @@ "placeholders": { "num": {"type": "int", "example": "2"}, "maxFileUploadSizeMib": {"type": "int", "example": "15"}, - "listMessage": {"type": "String", "example": "foo.txt\nbar.txt"} + "listMessage": {"type": "String", "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB"} } }, "errorFilesTooLargeTitle": "{num, plural, =1{File} other{Files}} too large", From c6db6aa0dbd857239ee5fd8a0a0e56094783e3bc Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Feb 2025 13:28:49 -0500 Subject: [PATCH 028/110] login [nfc]: Shorten string for server URL input label to match the labels for the other fields (loginEmailLabel, loginPasswordLabel, etc.) This also updates existing translations in other languages to match. Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 6 +++--- assets/l10n/app_pl.arb | 6 +++--- assets/l10n/app_ru.arb | 6 +++--- assets/l10n/app_sk.arb | 6 +++--- lib/generated/l10n/zulip_localizations.dart | 4 ++-- lib/generated/l10n/zulip_localizations_ar.dart | 2 +- lib/generated/l10n/zulip_localizations_en.dart | 2 +- lib/generated/l10n/zulip_localizations_ja.dart | 2 +- lib/generated/l10n/zulip_localizations_nb.dart | 2 +- lib/generated/l10n/zulip_localizations_pl.dart | 2 +- lib/generated/l10n/zulip_localizations_ru.dart | 2 +- lib/generated/l10n/zulip_localizations_sk.dart | 2 +- lib/widgets/login.dart | 2 +- 13 files changed, 22 insertions(+), 22 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 87edb26a0b..0640af9ee1 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -483,9 +483,9 @@ "@loginAddAnAccountPageTitle": { "description": "Title for page to add a Zulip account." }, - "loginServerUrlInputLabel": "Your Zulip server URL", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "loginServerUrlLabel": "Your Zulip server URL", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, "loginHidePassword": "Hide password", "@loginHidePassword": { diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index e51a31771d..770a670212 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -355,9 +355,9 @@ "@loginAddAnAccountPageTitle": { "description": "Title for page to add a Zulip account." }, - "loginServerUrlInputLabel": "URL serwera Zulip", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "loginServerUrlLabel": "URL serwera Zulip", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, "loginHidePassword": "Ukryj hasło", "@loginHidePassword": { diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 04e971cb4b..ef38533bb2 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -207,9 +207,9 @@ "@loginAddAnAccountPageTitle": { "description": "Title for page to add a Zulip account." }, - "loginServerUrlInputLabel": "URL вашего сервера Zulip", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "loginServerUrlLabel": "URL вашего сервера Zulip", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, "loginHidePassword": "Скрыть пароль", "@loginHidePassword": { diff --git a/assets/l10n/app_sk.arb b/assets/l10n/app_sk.arb index 4ad83e6790..087f459697 100644 --- a/assets/l10n/app_sk.arb +++ b/assets/l10n/app_sk.arb @@ -69,9 +69,9 @@ } } }, - "loginServerUrlInputLabel": "Adresa vášho Zulip servera", - "@loginServerUrlInputLabel": { - "description": "Input label in login page for Zulip server URL entry." + "loginServerUrlLabel": "Adresa vášho Zulip servera", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." }, "errorMessageNotSent": "Správa nebola odoslaná", "@errorMessageNotSent": { diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index bbd9f4f4c9..3cbd917563 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -741,11 +741,11 @@ abstract class ZulipLocalizations { /// **'Add an account'** String get loginAddAnAccountPageTitle; - /// Input label in login page for Zulip server URL entry. + /// Label in login page for Zulip server URL entry. /// /// In en, this message translates to: /// **'Your Zulip server URL'** - String get loginServerUrlInputLabel; + String get loginServerUrlLabel; /// Icon label for button to hide password in input form. /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index df0d1087db..0304fd3e6f 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -375,7 +375,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Add an account'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Your Zulip server URL'; @override String get loginHidePassword => 'Hide password'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 6d689e01a8..7af8cd7bab 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -375,7 +375,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Add an account'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Your Zulip server URL'; @override String get loginHidePassword => 'Hide password'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index dae7fc8bd2..6ac34645e2 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -375,7 +375,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Add an account'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Your Zulip server URL'; @override String get loginHidePassword => 'Hide password'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d5604f1be8..b3360e9f62 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -375,7 +375,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Add an account'; @override - String get loginServerUrlInputLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Your Zulip server URL'; @override String get loginHidePassword => 'Hide password'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 87dc1ce4ba..cab571c163 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -375,7 +375,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Dodaj konto'; @override - String get loginServerUrlInputLabel => 'URL serwera Zulip'; + String get loginServerUrlLabel => 'URL serwera Zulip'; @override String get loginHidePassword => 'Ukryj hasło'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 9d67cff8b0..babbc976fd 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -375,7 +375,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Добавление учетной записи'; @override - String get loginServerUrlInputLabel => 'URL вашего сервера Zulip'; + String get loginServerUrlLabel => 'URL вашего сервера Zulip'; @override String get loginHidePassword => 'Скрыть пароль'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index eaa1969d42..964dbc29ad 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -375,7 +375,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get loginAddAnAccountPageTitle => 'Pridať účet'; @override - String get loginServerUrlInputLabel => 'Adresa vášho Zulip servera'; + String get loginServerUrlLabel => 'Adresa vášho Zulip servera'; @override String get loginHidePassword => 'Skryť heslo'; diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index ce1cf09438..a970d8059c 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -230,7 +230,7 @@ class _AddAccountPageState extends State { // …but leave out unfocusing the input in case more editing is needed. }, decoration: InputDecoration( - labelText: zulipLocalizations.loginServerUrlInputLabel, + labelText: zulipLocalizations.loginServerUrlLabel, errorText: errorText, helperText: kLayoutPinningHelperText, hintText: 'your-org.zulipchat.com')), From 0417c87ef7ab6b30cd74c3d4fe8e65e0de3955db Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 7 Feb 2025 17:42:36 -0800 Subject: [PATCH 029/110] login [nfc]: Document need for "server URL" hint value to be reserved --- lib/widgets/login.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index a970d8059c..195bf75e62 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -115,6 +115,20 @@ class AddAccountPage extends StatefulWidget { return _LoginSequenceRoute(page: const AddAccountPage()); } + /// The hint text to show in the "Zulip server URL" input. + /// + /// If this contains an example value, it must be one that has been reserved + /// so that it cannot point to a real Zulip realm (nor any unknown other site). + /// The realm name `your-org` under zulipchat.com is reserved for this reason. + /// See discussion: + /// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/flutter.3A.20login.20URL/near/1570347 + // TODO(i18n): In principle this should be translated, because it's trying to + // convey to the user the English phrase "your org". But doing that is + // tricky because of the need to have the example name reserved. + // Realistically that probably means we'll only ever translate this for + // at most a handful of languages, most likely none. + static const _serverUrlHint = 'your-org.zulipchat.com'; + @override State createState() => _AddAccountPageState(); } @@ -233,7 +247,7 @@ class _AddAccountPageState extends State { labelText: zulipLocalizations.loginServerUrlLabel, errorText: errorText, helperText: kLayoutPinningHelperText, - hintText: 'your-org.zulipchat.com')), + hintText: AddAccountPage._serverUrlHint)), const SizedBox(height: 8), ElevatedButton( onPressed: !_inProgress && errorText == null From 6765f66c3faddb6dfddacafd120375e340ca7e63 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 2 Jan 2025 21:29:15 +0530 Subject: [PATCH 030/110] notif test: Clean up `TestPlatformDispatcher.defaultRouteNameTestValue` --- test/notifications/display_test.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 32d8254d6d..b72e97ed01 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1112,11 +1112,12 @@ void main() { realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + FcmMessageChannelRecipient(:var streamId, :var topic) => + TopicNarrow(streamId, topic), + FcmMessageDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).buildUrl(); + addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); // Now start the app. From 05628cab9cda744068d911560288de6513155fe7 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 27 Jan 2025 14:06:03 +0530 Subject: [PATCH 031/110] app: Query initial-account-id while handling initial routes This avoids a potential race if the queried account is logged out between the invocation of this Builder callback and `MaterialApp.onGenerateInitialRoutes` (if such a race is possible). --- lib/widgets/app.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index f20e9f8fcc..f248a1a600 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -178,8 +178,6 @@ class _ZulipAppState extends State with WidgetsBindingObserver { return GlobalStoreWidget( child: Builder(builder: (context) { final globalStore = GlobalStoreWidget.of(context); - // TODO(#524) choose initial account as last one used - final initialAccountId = globalStore.accounts.firstOrNull?.id; return MaterialApp( onGenerateTitle: (BuildContext context) { return ZulipLocalizations.of(context).zulipAppTitle; @@ -209,6 +207,8 @@ class _ZulipAppState extends State with WidgetsBindingObserver { onGenerateRoute: (_) => null, onGenerateInitialRoutes: (_) { + // TODO(#524) choose initial account as last one used + final initialAccountId = globalStore.accounts.firstOrNull?.id; return [ if (initialAccountId == null) MaterialWidgetRoute(page: const ChooseAccountPage()) From 4ece284be147c4b20e371c263a3eaf171d1df601 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 7 Feb 2025 18:02:18 +0530 Subject: [PATCH 032/110] app [nfc]: Reorder _ZulipAppState methods --- lib/widgets/app.dart | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index f248a1a600..8d32bac9e0 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -139,26 +139,6 @@ class ZulipApp extends StatefulWidget { } class _ZulipAppState extends State with WidgetsBindingObserver { - @override - Future didPushRouteInformation(routeInformation) async { - switch (routeInformation.uri) { - case Uri(scheme: 'zulip', host: 'login') && var url: - await LoginPage.handleWebAuthUrl(url); - return true; - case Uri(scheme: 'zulip', host: 'notification') && var url: - await NotificationDisplayManager.navigateForNotification(url); - return true; - } - return super.didPushRouteInformation(routeInformation); - } - - Future _handleInitialRoute() async { - final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - await NotificationDisplayManager.navigateForNotification(initialRouteUrl); - } - } - @override void initState() { super.initState(); @@ -172,6 +152,26 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } + Future _handleInitialRoute() async { + final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName); + if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { + await NotificationDisplayManager.navigateForNotification(initialRouteUrl); + } + } + + @override + Future didPushRouteInformation(routeInformation) async { + switch (routeInformation.uri) { + case Uri(scheme: 'zulip', host: 'login') && var url: + await LoginPage.handleWebAuthUrl(url); + return true; + case Uri(scheme: 'zulip', host: 'notification') && var url: + await NotificationDisplayManager.navigateForNotification(url); + return true; + } + return super.didPushRouteInformation(routeInformation); + } + @override Widget build(BuildContext context) { final themeData = zulipThemeData(context); From 6d7c751ea23161ca203ee8eb620b9629084590e1 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 2 Jan 2025 21:36:35 +0530 Subject: [PATCH 033/110] app: Pull out `_handleGenerateInitialRoutes` And remove the use of Builder widget, which is unncessary after this refactor. --- lib/widgets/app.dart | 87 +++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 8d32bac9e0..b6786ea3d1 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -152,6 +152,23 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } + List> _handleGenerateInitialRoutes(String initialRoute) { + // The `_ZulipAppState.context` lacks the required ancestors. Instead + // we use the Navigator which should be available when this callback is + // called and it's context should have the required ancestors. + final context = ZulipApp.navigatorKey.currentContext!; + final globalStore = GlobalStoreWidget.of(context); + + // TODO(#524) choose initial account as last one used + final initialAccountId = globalStore.accounts.firstOrNull?.id; + return [ + if (initialAccountId == null) + MaterialWidgetRoute(page: const ChooseAccountPage()) + else + HomePage.buildRoute(accountId: initialAccountId), + ]; + } + Future _handleInitialRoute() async { final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName); if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { @@ -176,47 +193,35 @@ class _ZulipAppState extends State with WidgetsBindingObserver { Widget build(BuildContext context) { final themeData = zulipThemeData(context); return GlobalStoreWidget( - child: Builder(builder: (context) { - final globalStore = GlobalStoreWidget.of(context); - return MaterialApp( - onGenerateTitle: (BuildContext context) { - return ZulipLocalizations.of(context).zulipAppTitle; - }, - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - theme: themeData, - - navigatorKey: ZulipApp.navigatorKey, - navigatorObservers: widget.navigatorObservers ?? const [], - builder: (BuildContext context, Widget? child) { - if (!ZulipApp.ready.value) { - SchedulerBinding.instance.addPostFrameCallback( - (_) => widget._declareReady()); - } - GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); - return child!; - }, - - // We use onGenerateInitialRoutes for the real work of specifying the - // initial nav state. To do that we need [MaterialApp] to decide to - // build a [Navigator]... which means specifying either `home`, `routes`, - // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`. - // It never actually gets called, though: `onGenerateInitialRoutes` - // handles startup, and then we always push whole routes with methods - // like [Navigator.push], never mere names as with [Navigator.pushNamed]. - onGenerateRoute: (_) => null, - - onGenerateInitialRoutes: (_) { - // TODO(#524) choose initial account as last one used - final initialAccountId = globalStore.accounts.firstOrNull?.id; - return [ - if (initialAccountId == null) - MaterialWidgetRoute(page: const ChooseAccountPage()) - else - HomePage.buildRoute(accountId: initialAccountId), - ]; - }); - })); + child: MaterialApp( + onGenerateTitle: (BuildContext context) { + return ZulipLocalizations.of(context).zulipAppTitle; + }, + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + theme: themeData, + + navigatorKey: ZulipApp.navigatorKey, + navigatorObservers: widget.navigatorObservers ?? const [], + builder: (BuildContext context, Widget? child) { + if (!ZulipApp.ready.value) { + SchedulerBinding.instance.addPostFrameCallback( + (_) => widget._declareReady()); + } + GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); + return child!; + }, + + // We use onGenerateInitialRoutes for the real work of specifying the + // initial nav state. To do that we need [MaterialApp] to decide to + // build a [Navigator]... which means specifying either `home`, `routes`, + // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`. + // It never actually gets called, though: `onGenerateInitialRoutes` + // handles startup, and then we always push whole routes with methods + // like [Navigator.push], never mere names as with [Navigator.pushNamed]. + onGenerateRoute: (_) => null, + + onGenerateInitialRoutes: _handleGenerateInitialRoutes)); } } From bd70287c0a95b68d7eb0bc95bc5c01e6de04898d Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 30 Jan 2025 14:56:21 -0800 Subject: [PATCH 034/110] page [nfc]: Add interface to get account ID for most of our routes --- lib/widgets/home.dart | 2 +- lib/widgets/message_list.dart | 2 +- lib/widgets/page.dart | 10 +++++++++- lib/widgets/profile.dart | 2 +- test/widgets/page_checks.dart | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index ad70b57c32..d7b1585022 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -31,7 +31,7 @@ enum _HomePageTab { class HomePage extends StatefulWidget { const HomePage({super.key}); - static Route buildRoute({required int accountId}) { + static AccountRoute buildRoute({required int accountId}) { return MaterialAccountWidgetRoute(accountId: accountId, loadingPlaceholderPage: _LoadingPlaceholderPage(accountId: accountId), page: const HomePage()); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 4263041dbc..25993efa30 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -188,7 +188,7 @@ abstract class MessageListPageState { class MessageListPage extends StatefulWidget { const MessageListPage({super.key, required this.initNarrow}); - static Route buildRoute({int? accountId, BuildContext? context, + static AccountRoute buildRoute({int? accountId, BuildContext? context, required Narrow narrow}) { return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: MessageListPage(initNarrow: narrow)); diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index 0ba65fb3d4..a2c6fe52a1 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -35,6 +35,12 @@ abstract class WidgetRoute extends PageRoute { Widget get page; } +/// A page route that specifies a particular Zulip account to use, by ID. +abstract class AccountRoute extends PageRoute { + /// The [Account.id] of the account to use for this page. + int get accountId; +} + /// A [MaterialPageRoute] that always builds the same widget. /// /// This is useful for making the route more transparent for a test to inspect. @@ -56,8 +62,10 @@ class MaterialWidgetRoute extends MaterialPageRoute implem } /// A mixin for providing a given account's per-account store on a page route. -mixin AccountPageRouteMixin on PageRoute { +mixin AccountPageRouteMixin on PageRoute implements AccountRoute { + @override int get accountId; + Widget? get loadingPlaceholderPage; @override diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index c4f8970ff0..327910f6c0 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -30,7 +30,7 @@ class ProfilePage extends StatelessWidget { final int userId; - static Route buildRoute({int? accountId, BuildContext? context, + static AccountRoute buildRoute({int? accountId, BuildContext? context, required int userId}) { return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: ProfilePage(userId: userId)); diff --git a/test/widgets/page_checks.dart b/test/widgets/page_checks.dart index 412a59fc49..a3692273bf 100644 --- a/test/widgets/page_checks.dart +++ b/test/widgets/page_checks.dart @@ -6,6 +6,6 @@ extension WidgetRouteChecks on Subject> { Subject get page => has((x) => x.page, 'page'); } -extension AccountPageRouteMixinChecks on Subject> { +extension AccountRouteChecks on Subject> { Subject get accountId => has((x) => x.accountId, 'accountId'); } From 4b2f51e0f9370252cc2959bfb5e584a83fb1233e Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 30 Dec 2024 23:28:20 +0530 Subject: [PATCH 035/110] notif: Use associated account as initial account, if opened from background Previously, when two accounts (Account-1 and Account-2) were logged in, the app always defaulted to showing the home page of Account-1 on launch. If the app was closed and the user opened a notification from Account-2, the navigation stack would be: HomePage(Account-1) -> MessageListPage(Account-2) This commit fixes that behaviour, now when a notification is opened while the app is closed, the home page will correspond to the account associated with the notification's conversation. This addresses #1210 for background notifications. --- lib/notifications/display.dart | 52 +++++++++++++++++++--------- lib/widgets/app.dart | 27 ++++++++++----- test/notifications/display_test.dart | 35 +++++++++++++++++-- 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 1b85ccaebc..210a08ee57 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -453,6 +453,39 @@ class NotificationDisplayManager { static String _personKey(Uri realmUrl, int userId) => "$realmUrl|$userId"; + /// Provides the route and the account ID by parsing the notification URL. + /// + /// The URL must have been generated using [NotificationOpenPayload.buildUrl] + /// while creating the notification. + /// + /// Returns null and shows an error dialog if the associated account is not + /// found in the global store. + static AccountRoute? routeForNotification({ + required BuildContext context, + required Uri url, + }) { + final globalStore = GlobalStoreWidget.of(context); + + assert(debugLog('got notif: url: $url')); + assert(url.scheme == 'zulip' && url.host == 'notification'); + final payload = NotificationOpenPayload.parseUrl(url); + + final account = globalStore.accounts.firstWhereOrNull((account) => + account.realmUrl == payload.realmUrl && account.userId == payload.userId); + if (account == null) { // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle, + message: zulipLocalizations.errorNotificationOpenAccountMissing); + return null; + } + + return MessageListPage.buildRoute( + accountId: account.id, + // TODO(#82): Open at specific message, not just conversation + narrow: payload.narrow); + } + /// Navigates to the [MessageListPage] of the specific conversation /// given the `zulip://notification/…` Android intent data URL, /// generated with [NotificationOpenPayload.buildUrl] while creating @@ -460,29 +493,16 @@ class NotificationDisplayManager { static Future navigateForNotification(Uri url) async { assert(debugLog('opened notif: url: $url')); - assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseUrl(url); - NavigatorState navigator = await ZulipApp.navigator; final context = navigator.context; assert(context.mounted); if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that - final zulipLocalizations = ZulipLocalizations.of(context); - final globalStore = GlobalStoreWidget.of(context); - final account = globalStore.accounts.firstWhereOrNull((account) => - account.realmUrl == payload.realmUrl && account.userId == payload.userId); - if (account == null) { // TODO(log) - showErrorDialog(context: context, - title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); - return; - } + final route = routeForNotification(context: context, url: url); + if (route == null) return; // TODO(log) // TODO(nav): Better interact with existing nav stack on notif open - unawaited(navigator.push(MaterialAccountWidgetRoute(accountId: account.id, - // TODO(#82): Open at specific message, not just conversation - page: MessageListPage(initNarrow: payload.narrow)))); + unawaited(navigator.push(route)); } static Future _fetchBitmap(Uri url) async { diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index b6786ea3d1..9fa82144a1 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -143,7 +143,6 @@ class _ZulipAppState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _handleInitialRoute(); } @override @@ -157,8 +156,25 @@ class _ZulipAppState extends State with WidgetsBindingObserver { // we use the Navigator which should be available when this callback is // called and it's context should have the required ancestors. final context = ZulipApp.navigatorKey.currentContext!; - final globalStore = GlobalStoreWidget.of(context); + final initialRouteUrl = Uri.tryParse(initialRoute); + if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { + final route = NotificationDisplayManager.routeForNotification( + context: context, + url: initialRouteUrl); + + if (route != null) { + return [ + HomePage.buildRoute(accountId: route.accountId), + route, + ]; + } else { + // The account didn't match any existing accounts, + // fall through to show the default route below. + } + } + + final globalStore = GlobalStoreWidget.of(context); // TODO(#524) choose initial account as last one used final initialAccountId = globalStore.accounts.firstOrNull?.id; return [ @@ -169,13 +185,6 @@ class _ZulipAppState extends State with WidgetsBindingObserver { ]; } - Future _handleInitialRoute() async { - final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - await NotificationDisplayManager.navigateForNotification(initialRouteUrl); - } - } - @override Future didPushRouteInformation(routeInformation) async { switch (routeInformation.uri) { diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index b72e97ed01..51ec661930 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -960,11 +960,12 @@ void main() { group('NotificationDisplayManager open', () { late List> pushedRoutes; - void takeStartingRoutes({bool withAccount = true}) { + void takeStartingRoutes({Account? account, bool withAccount = true}) { + account ??= eg.selfAccount; final expected = >[ if (withAccount) (it) => it.isA() - ..accountId.equals(eg.selfAccount.id) + ..accountId.equals(account!.id) ..page.isA() else (it) => it.isA().page.isA(), @@ -1130,6 +1131,36 @@ void main() { takeStartingRoutes(); matchesNavigation(check(pushedRoutes).single, account, message); }); + + testWidgets('uses associated account as initial account; if initial route', (tester) async { + addTearDown(testBinding.reset); + + final accountA = eg.selfAccount; + final accountB = eg.otherAccount; + final message = eg.streamMessage(); + final data = messageFcmMessage(message, account: accountB); + await testBinding.globalStore.add(accountA, eg.initialSnapshot()); + await testBinding.globalStore.add(accountB, eg.initialSnapshot()); + + final intentDataUrl = NotificationOpenPayload( + realmUrl: data.realmUrl, + userId: data.userId, + narrow: switch (data.recipient) { + FcmMessageChannelRecipient(:var streamId, :var topic) => + TopicNarrow(streamId, topic), + FcmMessageDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).buildUrl(); + addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + await tester.pump(); + takeStartingRoutes(account: accountB); + matchesNavigation(check(pushedRoutes).single, accountB, message); + }); }); group('NotificationOpenPayload', () { From fb1b97fb3c970d3b96b4bdfad550cf35d20c702e Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 28 Jan 2025 20:17:04 +0530 Subject: [PATCH 036/110] notif: Query account by realm URL origin, not full URL This fixes a potential bug, in case the server returned `realm_url` contains a trailing `/`. --- lib/notifications/display.dart | 5 +++-- test/notifications/display_test.dart | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 210a08ee57..1d74db6854 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -470,8 +470,9 @@ class NotificationDisplayManager { assert(url.scheme == 'zulip' && url.host == 'notification'); final payload = NotificationOpenPayload.parseUrl(url); - final account = globalStore.accounts.firstWhereOrNull((account) => - account.realmUrl == payload.realmUrl && account.userId == payload.userId); + final account = globalStore.accounts.firstWhereOrNull( + (account) => account.realmUrl.origin == payload.realmUrl.origin + && account.userId == payload.userId); if (account == null) { // TODO(log) final zulipLocalizations = ZulipLocalizations.of(context); showErrorDialog(context: context, diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 51ec661930..b1c56b55b1 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1037,6 +1037,21 @@ void main() { eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); }); + testWidgets('account queried by realmUrl origin component', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add( + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), + eg.initialSnapshot()); + await prepare(tester); + + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')), + eg.streamMessage()); + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), + eg.streamMessage()); + }); + testWidgets('no accounts', (tester) async { await prepare(tester, withAccount: false); await openNotification(tester, eg.selfAccount, eg.streamMessage()); From 9aafbd43e98f9fbda10d1f797dc4c6b3d45a667f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 23 Jan 2025 17:40:37 -0800 Subject: [PATCH 037/110] doc: Add libdrm troubleshooting entry I ran into this today when I tried running the Linux app. (It looks like the reason I hadn't seen it before is that until recently I had this package installed; it was installed "automatically", i.e. only because it was a dependency of something else, and then I upgraded my machine, it was no longer such a dependency, and got autoremoved. Now that I've installed it directly, it'll stay.) --- docs/setup.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/setup.md b/docs/setup.md index 6a83cfa55c..03ecc87996 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -179,3 +179,37 @@ For the original reports and debugging of this issue, see chat threads [here](https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.20json_annotation.20unexpected.20behavior/near/1824410) and [here](https://chat.zulip.org/#narrow/stream/516-mobile-dev-help/topic/generated.20plugin.20files.20changed/near/1944826). + + +
+ +### Lack of libdrm on Linux target + +This item applies only when building the app to run as a Linux desktop +app. (This is an unsupported configuration which is sometimes +convenient in development.) It does not affect using Linux for a +development environment when building or running Zulip as an Android +app. + +When building or running as a Linux desktop app, you may see an error +about `/usr/include/libdrm`, like this: +``` +$ flutter run -d linux +Launching lib/main.dart on Linux in debug mode... +CMake Error in CMakeLists.txt: + Imported target "PkgConfig::GTK" includes non-existent path + + "/usr/include/libdrm" + + in its INTERFACE_INCLUDE_DIRECTORIES. Possible reasons include: +… +``` + +This means you need to install the header files for "DRM", part of the +Linux graphics infrastructure. + +To resolve the issue, install the appropriate package from your OS +distribution. For example, on Debian or Ubuntu: +``` +$ sudo apt install libdrm-dev +``` From 1e8397be156ab603a89c2b024a12d4e2028e1acc Mon Sep 17 00:00:00 2001 From: lakshya1goel Date: Wed, 12 Feb 2025 19:31:00 +0530 Subject: [PATCH 038/110] msglist: Update label to "Messages with yourself" in DM header Related CZO Discussion: https://chat.zulip.org/#narrow/channel/137-feedback/topic/Chat.20with.20myself/with/2086462 Fixes: #1319 --- assets/l10n/app_en.arb | 2 +- lib/generated/l10n/zulip_localizations.dart | 2 +- lib/generated/l10n/zulip_localizations_ar.dart | 2 +- lib/generated/l10n/zulip_localizations_en.dart | 2 +- lib/generated/l10n/zulip_localizations_ja.dart | 2 +- lib/generated/l10n/zulip_localizations_nb.dart | 2 +- lib/generated/l10n/zulip_localizations_sk.dart | 2 +- lib/widgets/message_list.dart | 1 - 8 files changed, 7 insertions(+), 8 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 0640af9ee1..319c9e9dbc 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -404,7 +404,7 @@ "others": {"type": "String", "example": "Alice, Bob"} } }, - "messageListGroupYouWithYourself": "You with yourself", + "messageListGroupYouWithYourself": "Messages with yourself", "@messageListGroupYouWithYourself": { "description": "Message list recipient header for a DM group that only includes yourself." }, diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 3cbd917563..b3a8752ba1 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -630,7 +630,7 @@ abstract class ZulipLocalizations { /// Message list recipient header for a DM group that only includes yourself. /// /// In en, this message translates to: - /// **'You with yourself'** + /// **'Messages with yourself'** String get messageListGroupYouWithYourself; /// Content validation error message when the message is too long. diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 0304fd3e6f..967c7fb33c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -316,7 +316,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get messageListGroupYouWithYourself => 'Messages with yourself'; @override String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 7af8cd7bab..83d2af10b6 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -316,7 +316,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get messageListGroupYouWithYourself => 'Messages with yourself'; @override String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 6ac34645e2..034bcd17d0 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -316,7 +316,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get messageListGroupYouWithYourself => 'Messages with yourself'; @override String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index b3360e9f62..6416f59b08 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -316,7 +316,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get messageListGroupYouWithYourself => 'Messages with yourself'; @override String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 964dbc29ad..ac3b93b024 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -316,7 +316,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { } @override - String get messageListGroupYouWithYourself => 'You with yourself'; + String get messageListGroupYouWithYourself => 'Messages with yourself'; @override String get contentValidationErrorTooLong => 'Message length shouldn\'t be greater than 10000 characters.'; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 25993efa30..57259640b1 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1179,7 +1179,6 @@ class DmRecipientHeader extends StatelessWidget { .sorted() .join(", ")); } else { - // TODO pick string; web has glitchy "You and $yourname" title = zulipLocalizations.messageListGroupYouWithYourself; } From f6655b87a99fbd6fd32ff1fb4ac07dc59177fd07 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 6 Jan 2025 17:35:53 +0800 Subject: [PATCH 039/110] login [nfc]: Pass GlobalStore to logOutAccount This allows us to call it from model code when GlobalStore is available. Signed-off-by: Zixuan James Li --- lib/widgets/actions.dart | 4 +--- lib/widgets/app.dart | 2 +- test/widgets/actions_test.dart | 7 +++---- test/widgets/home_test.dart | 6 ++---- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index 86c6e44875..5ac1d732c8 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -20,9 +20,7 @@ import '../notifications/receive.dart'; import 'dialog.dart'; import 'store.dart'; -Future logOutAccount(BuildContext context, int accountId) async { - final globalStore = GlobalStoreWidget.of(context); - +Future logOutAccount(GlobalStore globalStore, int accountId) async { final account = globalStore.getAccount(accountId); if (account == null) return; // TODO(log) diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 9fa82144a1..0390eda7fe 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -265,7 +265,7 @@ class ChooseAccountPage extends StatelessWidget { actionButtonText: zulipLocalizations.logOutConfirmationDialogConfirmButton, onActionButtonPress: () { // TODO error handling if db write fails? - logOutAccount(context, accountId); + logOutAccount(GlobalStoreWidget.of(context), accountId); }); }, child: Text(zulipLocalizations.chooseAccountPageLogOutButton)), diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 5a957c44e0..b7423c4529 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -109,7 +109,7 @@ void main() { final newConnection = separateConnection() ..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'}); - final future = logOutAccount(context, eg.selfAccount.id); + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); // Unregister-token request and account removal dispatched together checkSingleUnregisterRequest(newConnection); check(testBinding.globalStore.takeDoRemoveAccountCalls()) @@ -141,7 +141,7 @@ void main() { final newConnection = separateConnection() ..prepare(delay: unregisterDelay, exception: exception); - final future = logOutAccount(context, eg.selfAccount.id); + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); // Unregister-token request and account removal dispatched together checkSingleUnregisterRequest(newConnection); check(testBinding.globalStore.takeDoRemoveAccountCalls()) @@ -210,8 +210,7 @@ void main() { final removedRoutes = >[]; testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); - final context = tester.element(find.byType(MaterialApp)); - final future = logOutAccount(context, account1.id); + final future = logOutAccount(testBinding.globalStore, account1.id); await tester.pump(TestGlobalStore.removeAccountDuration); await future; check(removedRoutes).single.identicalTo(account1Route); diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 5bb789727f..a599ad9d1a 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -453,8 +453,7 @@ void main () { await tester.pump(); // wait for the loading page checkOnLoadingPage(); - final element = tester.element(find.byType(MaterialApp)); - final future = logOutAccount(element, eg.selfAccount.id); + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); await tester.pump(TestGlobalStore.removeAccountDuration); await future; // No error expected from briefly not having @@ -471,8 +470,7 @@ void main () { await tester.pump(); // wait for store checkOnHomePage(tester, expectedAccount: eg.selfAccount); - final element = tester.element(find.byType(HomePage)); - final future = logOutAccount(element, eg.selfAccount.id); + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); await tester.pump(TestGlobalStore.removeAccountDuration); await future; // No error expected from briefly not having From 8b601be6508f7bacaf23a1f8cdd6a6f30ed42761 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 11 Feb 2025 16:20:05 -0500 Subject: [PATCH 040/110] actions test [nfc]: Move a store-related test to a better home This is more about testing the implementation of PerAccountStoreWidget to handle routeToRemoveOnLogout, instead of logOutAccount. Signed-off-by: Zixuan James Li --- test/widgets/actions_test.dart | 71 -------------------------------- test/widgets/store_test.dart | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 71 deletions(-) diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index b7423c4529..73351dda08 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -1,10 +1,8 @@ -import 'dart:async'; import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:zulip/api/exception.dart'; @@ -17,9 +15,6 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/actions.dart'; -import 'package:zulip/widgets/app.dart'; -import 'package:zulip/widgets/inbox.dart'; -import 'package:zulip/widgets/page.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -28,7 +23,6 @@ import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../model/unreads_checks.dart'; import '../stdlib_checks.dart'; -import '../test_navigation.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -157,71 +151,6 @@ void main() { await tester.pump(unregisterDelay - TestGlobalStore.removeAccountDuration); check(newConnection.isOpen).isFalse(); }); - - testWidgets("logged-out account's routes removed from nav; other accounts' remain", (tester) async { - Future makeUnreadTopicInInbox(int accountId, String topic) async { - final stream = eg.stream(); - final message = eg.streamMessage(stream: stream, topic: topic); - final store = await testBinding.globalStore.perAccount(accountId); - await store.addStream(stream); - await store.addSubscription(eg.subscription(stream)); - await store.addMessage(message); - await tester.pump(); - } - - addTearDown(testBinding.reset); - - final account1 = eg.account(id: 1, user: eg.user()); - final account2 = eg.account(id: 2, user: eg.user()); - await testBinding.globalStore.add(account1, eg.initialSnapshot()); - await testBinding.globalStore.add(account2, eg.initialSnapshot()); - - final testNavObserver = TestNavigatorObserver(); - await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); - await tester.pump(); - final navigator = await ZulipApp.navigator; - navigator.popUntil((_) => false); // clear starting routes - await tester.pumpAndSettle(); - - final pushedRoutes = >[]; - testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); - // TODO(#737): switch to a realistic setup: - // https://github.com/zulip/zulip-flutter/pull/1076#discussion_r1874124363 - final account1Route = MaterialAccountWidgetRoute( - accountId: account1.id, page: const InboxPageBody()); - final account2Route = MaterialAccountWidgetRoute( - accountId: account2.id, page: const InboxPageBody()); - unawaited(navigator.push(account1Route)); - unawaited(navigator.push(account2Route)); - await tester.pumpAndSettle(); - check(pushedRoutes).deepEquals([account1Route, account2Route]); - - await makeUnreadTopicInInbox(account1.id, 'topic in account1'); - final findAccount1PageContent = find.text('topic in account1', skipOffstage: false); - - await makeUnreadTopicInInbox(account2.id, 'topic in account2'); - final findAccount2PageContent = find.text('topic in account2', skipOffstage: false); - - final findLoadingPage = find.byType(LoadingPlaceholderPage, skipOffstage: false); - - check(findAccount1PageContent).findsOne(); - check(findLoadingPage).findsNothing(); - - final removedRoutes = >[]; - testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); - - final future = logOutAccount(testBinding.globalStore, account1.id); - await tester.pump(TestGlobalStore.removeAccountDuration); - await future; - check(removedRoutes).single.identicalTo(account1Route); - check(findAccount1PageContent).findsNothing(); - check(findLoadingPage).findsOne(); - - await tester.pump(); - check(findAccount1PageContent).findsNothing(); - check(findLoadingPage).findsNothing(); - check(findAccount2PageContent).findsOne(); - }); }); group('unregisterToken', () { diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index e2d7821ae3..a8274346a8 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -1,13 +1,22 @@ +import 'dart:async'; + import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/actions.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/inbox.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../example_data.dart' as eg; import '../model/store_checks.dart'; +import '../model/test_store.dart'; +import '../test_navigation.dart'; /// A widget whose state uses [PerAccountStoreAwareStateMixin]. class MyWidgetWithMixin extends StatefulWidget { @@ -167,6 +176,71 @@ void main() { tester.widget(find.text('found store, account: ${eg.selfAccount.id}')); }); + testWidgets("PerAccountStoreWidget.routeToRemoveOnLogout logged-out account's routes removed from nav; other accounts' remain", (tester) async { + Future makeUnreadTopicInInbox(int accountId, String topic) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, topic: topic); + final store = await testBinding.globalStore.perAccount(accountId); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await store.addMessage(message); + await tester.pump(); + } + + addTearDown(testBinding.reset); + + final account1 = eg.account(id: 1, user: eg.user()); + final account2 = eg.account(id: 2, user: eg.user()); + await testBinding.globalStore.add(account1, eg.initialSnapshot()); + await testBinding.globalStore.add(account2, eg.initialSnapshot()); + + final testNavObserver = TestNavigatorObserver(); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); + final navigator = await ZulipApp.navigator; + navigator.popUntil((_) => false); // clear starting routes + await tester.pumpAndSettle(); + + final pushedRoutes = >[]; + testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); + // TODO(#737): switch to a realistic setup: + // https://github.com/zulip/zulip-flutter/pull/1076#discussion_r1874124363 + final account1Route = MaterialAccountWidgetRoute( + accountId: account1.id, page: const InboxPageBody()); + final account2Route = MaterialAccountWidgetRoute( + accountId: account2.id, page: const InboxPageBody()); + unawaited(navigator.push(account1Route)); + unawaited(navigator.push(account2Route)); + await tester.pumpAndSettle(); + check(pushedRoutes).deepEquals([account1Route, account2Route]); + + await makeUnreadTopicInInbox(account1.id, 'topic in account1'); + final findAccount1PageContent = find.text('topic in account1', skipOffstage: false); + + await makeUnreadTopicInInbox(account2.id, 'topic in account2'); + final findAccount2PageContent = find.text('topic in account2', skipOffstage: false); + + final findLoadingPage = find.byType(LoadingPlaceholderPage, skipOffstage: false); + + check(findAccount1PageContent).findsOne(); + check(findLoadingPage).findsNothing(); + + final removedRoutes = >[]; + testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); + + final future = logOutAccount(testBinding.globalStore, account1.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + check(removedRoutes).single.identicalTo(account1Route); + check(findAccount1PageContent).findsNothing(); + check(findLoadingPage).findsOne(); + + await tester.pump(); + check(findAccount1PageContent).findsNothing(); + check(findLoadingPage).findsNothing(); + check(findAccount2PageContent).findsOne(); + }); + testWidgets('PerAccountStoreAwareStateMixin', (tester) async { final widgetWithMixinKey = GlobalKey(); final accountId = eg.selfAccount.id; From d186ed02bdb50243c092c9699cd91a56d504305a Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 16 Jan 2025 18:07:13 -0500 Subject: [PATCH 041/110] actions [nfc]: Move logOutAccount and unregisterToken under lib/model plus moving and refactoring the tests to match Signed-off-by: Zixuan James Li --- lib/model/actions.dart | 36 +++++++ lib/widgets/actions.dart | 33 ------ lib/widgets/app.dart | 2 +- test/model/actions_test.dart | 188 +++++++++++++++++++++++++++++++++ test/widgets/actions_test.dart | 161 ---------------------------- test/widgets/home_test.dart | 2 +- test/widgets/store_test.dart | 2 +- 7 files changed, 227 insertions(+), 197 deletions(-) create mode 100644 lib/model/actions.dart create mode 100644 test/model/actions_test.dart diff --git a/lib/model/actions.dart b/lib/model/actions.dart new file mode 100644 index 0000000000..a88eea0777 --- /dev/null +++ b/lib/model/actions.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import '../notifications/receive.dart'; +import 'store.dart'; + +// TODO: Make this a part of GlobalStore +Future logOutAccount(GlobalStore globalStore, int accountId) async { + final account = globalStore.getAccount(accountId); + if (account == null) return; // TODO(log) + + // Unawaited, to not block removing the account on this request. + unawaited(unregisterToken(globalStore, accountId)); + + await globalStore.removeAccount(accountId); +} + +Future unregisterToken(GlobalStore globalStore, int accountId) async { + final account = globalStore.getAccount(accountId); + if (account == null) return; // TODO(log) + + // TODO(#322) use actual acked push token; until #322, this is just null. + final token = account.ackedPushToken + // Try the current token as a fallback; maybe the server has registered + // it and we just haven't recorded that fact in the client. + ?? NotificationService.instance.token.value; + if (token == null) return; + + final connection = globalStore.apiConnectionFromAccount(account); + try { + await NotificationService.unregisterToken(connection, token: token); + } catch (e) { + // TODO retry? handle failures? + } finally { + connection.close(); + } +} diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index 5ac1d732c8..2df502ec6c 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -15,42 +15,9 @@ import '../api/model/narrow.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; -import '../model/store.dart'; -import '../notifications/receive.dart'; import 'dialog.dart'; import 'store.dart'; -Future logOutAccount(GlobalStore globalStore, int accountId) async { - final account = globalStore.getAccount(accountId); - if (account == null) return; // TODO(log) - - // Unawaited, to not block removing the account on this request. - unawaited(unregisterToken(globalStore, accountId)); - - await globalStore.removeAccount(accountId); -} - -Future unregisterToken(GlobalStore globalStore, int accountId) async { - final account = globalStore.getAccount(accountId); - if (account == null) return; // TODO(log) - - // TODO(#322) use actual acked push token; until #322, this is just null. - final token = account.ackedPushToken - // Try the current token as a fallback; maybe the server has registered - // it and we just haven't recorded that fact in the client. - ?? NotificationService.instance.token.value; - if (token == null) return; - - final connection = globalStore.apiConnectionFromAccount(account); - try { - await NotificationService.unregisterToken(connection, token: token); - } catch (e) { - // TODO retry? handle failures? - } finally { - connection.close(); - } -} - Future markNarrowAsRead(BuildContext context, Narrow narrow) async { final store = PerAccountStoreWidget.of(context); final connection = store.connection; diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 0390eda7fe..2ad35e3e53 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -6,11 +6,11 @@ import 'package:flutter/scheduler.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../log.dart'; +import '../model/actions.dart'; import '../model/localizations.dart'; import '../model/store.dart'; import '../notifications/display.dart'; import 'about_zulip.dart'; -import 'actions.dart'; import 'dialog.dart'; import 'home.dart'; import 'login.dart'; diff --git a/test/model/actions_test.dart b/test/model/actions_test.dart new file mode 100644 index 0000000000..e24bb3223c --- /dev/null +++ b/test/model/actions_test.dart @@ -0,0 +1,188 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:zulip/api/exception.dart'; +import 'package:zulip/model/actions.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications/receive.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../fake_async.dart'; +import '../model/binding.dart'; +import '../model/store_checks.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import 'store_test.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + + Future prepare({String? ackedPushToken = '123'}) async { + addTearDown(testBinding.reset); + final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken)); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(selfAccount.id); + connection = store.connection as FakeApiConnection; + } + + /// Creates and caches a new [FakeApiConnection] in [TestGlobalStore]. + /// + /// In live code, [unregisterToken] makes a new [ApiConnection] for the + /// unregister-token request instead of reusing the store's connection. + /// To enable callers to prepare responses for that request, this function + /// creates a new [FakeApiConnection] and caches it in [TestGlobalStore] + /// for [unregisterToken] to pick up. + /// + /// Call this instead of just turning on + /// [TestGlobalStore.useCachedApiConnections] so that [unregisterToken] + /// doesn't try to call `close` twice on the same connection instance, + /// which isn't allowed. (Once by the unregister-token code + /// and once as part of removing the account.) + FakeApiConnection separateConnection() { + testBinding.globalStore + ..clearCachedApiConnections() + ..useCachedApiConnections = true; + return testBinding.globalStore + .apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + } + + String unregisterApiPathForPlatform(TargetPlatform platform) { + return switch (platform) { + TargetPlatform.android => '/api/v1/users/me/android_gcm_reg_id', + TargetPlatform.iOS => '/api/v1/users/me/apns_device_token', + _ => throw Error(), + }; + } + + void checkSingleUnregisterRequest( + FakeApiConnection connection, { + String? expectedToken, + }) { + final subject = check(connection.takeRequests()).single.isA() + ..method.equals('DELETE') + ..url.path.equals(unregisterApiPathForPlatform(defaultTargetPlatform)); + if (expectedToken != null) { + subject.bodyFields.deepEquals({'token': expectedToken}); + } + } + + group('logOutAccount', () { + test('smoke', () => awaitFakeAsync((async) async { + await prepare(); + check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); + const unregisterDelay = Duration(seconds: 5); + assert(unregisterDelay > TestGlobalStore.removeAccountDuration); + final newConnection = separateConnection() + ..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'}); + + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + // Unregister-token request and account removal dispatched together + checkSingleUnregisterRequest(newConnection); + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + await future; + // Account removal not blocked on unregister-token response + check(testBinding.globalStore).accountIds.isEmpty(); + check(connection.isOpen).isFalse(); + check(newConnection.isOpen).isTrue(); // still busy with unregister-token + + async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration); + check(newConnection.isOpen).isFalse(); + })); + + test('unregister request has an error', () => awaitFakeAsync((async) async { + await prepare(); + check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); + const unregisterDelay = Duration(seconds: 5); + assert(unregisterDelay > TestGlobalStore.removeAccountDuration); + final exception = ZulipApiException( + httpStatus: 401, + code: 'UNAUTHORIZED', + data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, + routeName: 'removeEtcEtcToken', + message: 'Invalid API key', + ); + final newConnection = separateConnection() + ..prepare(delay: unregisterDelay, exception: exception); + + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + // Unregister-token request and account removal dispatched together + checkSingleUnregisterRequest(newConnection); + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + await future; + // Account removal not blocked on unregister-token response + check(testBinding.globalStore).accountIds.isEmpty(); + check(connection.isOpen).isFalse(); + check(newConnection.isOpen).isTrue(); // for the unregister-token request + + async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration); + check(newConnection.isOpen).isFalse(); + })); + }); + + group('unregisterToken', () { + testAndroidIos('smoke, happy path', () => awaitFakeAsync((async) async { + await prepare(ackedPushToken: '123'); + + final newConnection = separateConnection() + ..prepare(json: {'msg': '', 'result': 'success'}); + final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); + async.elapse(Duration.zero); + await future; + checkSingleUnregisterRequest(newConnection, expectedToken: '123'); + check(newConnection.isOpen).isFalse(); + })); + + test('fallback to current token if acked is missing', () => awaitFakeAsync((async) async { + await prepare(ackedPushToken: null); + NotificationService.instance.token = ValueNotifier('asdf'); + + final newConnection = separateConnection() + ..prepare(json: {'msg': '', 'result': 'success'}); + final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); + async.elapse(Duration.zero); + await future; + checkSingleUnregisterRequest(newConnection, expectedToken: 'asdf'); + check(newConnection.isOpen).isFalse(); + })); + + test('no error if acked token and current token both missing', () => awaitFakeAsync((async) async { + await prepare(ackedPushToken: null); + NotificationService.instance.token = ValueNotifier(null); + + final newConnection = separateConnection(); + final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); + async.flushTimers(); + await future; + check(newConnection.takeRequests()).isEmpty(); + })); + + test('connection closed if request errors', () => awaitFakeAsync((async) async { + await prepare(ackedPushToken: '123'); + + final newConnection = separateConnection() + ..prepare(exception: ZulipApiException( + httpStatus: 401, + code: 'UNAUTHORIZED', + data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, + routeName: 'removeEtcEtcToken', + message: 'Invalid API key', + )); + final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); + async.elapse(Duration.zero); + await future; + checkSingleUnregisterRequest(newConnection, expectedToken: '123'); + check(newConnection.isOpen).isFalse(); + })); + }); +} diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 73351dda08..290d6f3e53 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -1,11 +1,9 @@ import 'dart:convert'; import 'package:checks/checks.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; @@ -13,14 +11,11 @@ import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; -import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/actions.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; -import '../model/store_checks.dart'; -import '../model/test_store.dart'; import '../model/unreads_checks.dart'; import '../stdlib_checks.dart'; import 'dialog_checks.dart'; @@ -53,162 +48,6 @@ void main() { context = tester.element(find.byType(Placeholder)); } - /// Creates and caches a new [FakeApiConnection] in [TestGlobalStore]. - /// - /// In live code, [unregisterToken] makes a new [ApiConnection] for the - /// unregister-token request instead of reusing the store's connection. - /// To enable callers to prepare responses for that request, this function - /// creates a new [FakeApiConnection] and caches it in [TestGlobalStore] - /// for [unregisterToken] to pick up. - /// - /// Call this instead of just turning on - /// [TestGlobalStore.useCachedApiConnections] so that [unregisterToken] - /// doesn't try to call `close` twice on the same connection instance, - /// which isn't allowed. (Once by the unregister-token code - /// and once as part of removing the account.) - FakeApiConnection separateConnection() { - testBinding.globalStore - ..clearCachedApiConnections() - ..useCachedApiConnections = true; - return testBinding.globalStore - .apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; - } - - String unregisterApiPathForPlatform(TargetPlatform platform) { - return switch (platform) { - TargetPlatform.android => '/api/v1/users/me/android_gcm_reg_id', - TargetPlatform.iOS => '/api/v1/users/me/apns_device_token', - _ => throw Error(), - }; - } - - void checkSingleUnregisterRequest( - FakeApiConnection connection, { - String? expectedToken, - }) { - final subject = check(connection.takeRequests()).single.isA() - ..method.equals('DELETE') - ..url.path.equals(unregisterApiPathForPlatform(defaultTargetPlatform)); - if (expectedToken != null) { - subject.bodyFields.deepEquals({'token': expectedToken}); - } - } - - group('logOutAccount', () { - testWidgets('smoke', (tester) async { - await prepare(tester, skipAssertAccountExists: true); - check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); - const unregisterDelay = Duration(seconds: 5); - assert(unregisterDelay > TestGlobalStore.removeAccountDuration); - final newConnection = separateConnection() - ..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'}); - - final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); - // Unregister-token request and account removal dispatched together - checkSingleUnregisterRequest(newConnection); - check(testBinding.globalStore.takeDoRemoveAccountCalls()) - .single.equals(eg.selfAccount.id); - - await tester.pump(TestGlobalStore.removeAccountDuration); - await future; - // Account removal not blocked on unregister-token response - check(testBinding.globalStore).accountIds.isEmpty(); - check(connection.isOpen).isFalse(); - check(newConnection.isOpen).isTrue(); // still busy with unregister-token - - await tester.pump(unregisterDelay - TestGlobalStore.removeAccountDuration); - check(newConnection.isOpen).isFalse(); - }); - - testWidgets('unregister request has an error', (tester) async { - await prepare(tester, skipAssertAccountExists: true); - check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); - const unregisterDelay = Duration(seconds: 5); - assert(unregisterDelay > TestGlobalStore.removeAccountDuration); - final exception = ZulipApiException( - httpStatus: 401, - code: 'UNAUTHORIZED', - data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, - routeName: 'removeEtcEtcToken', - message: 'Invalid API key', - ); - final newConnection = separateConnection() - ..prepare(delay: unregisterDelay, exception: exception); - - final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); - // Unregister-token request and account removal dispatched together - checkSingleUnregisterRequest(newConnection); - check(testBinding.globalStore.takeDoRemoveAccountCalls()) - .single.equals(eg.selfAccount.id); - - await tester.pump(TestGlobalStore.removeAccountDuration); - await future; - // Account removal not blocked on unregister-token response - check(testBinding.globalStore).accountIds.isEmpty(); - check(connection.isOpen).isFalse(); - check(newConnection.isOpen).isTrue(); // for the unregister-token request - - await tester.pump(unregisterDelay - TestGlobalStore.removeAccountDuration); - check(newConnection.isOpen).isFalse(); - }); - }); - - group('unregisterToken', () { - testWidgets('smoke, happy path', (tester) async { - await prepare(tester, ackedPushToken: '123'); - - final newConnection = separateConnection() - ..prepare(json: {'msg': '', 'result': 'success'}); - final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); - await tester.pump(Duration.zero); - await future; - checkSingleUnregisterRequest(newConnection, expectedToken: '123'); - check(newConnection.isOpen).isFalse(); - }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); - - testWidgets('fallback to current token if acked is missing', (tester) async { - await prepare(tester, ackedPushToken: null); - NotificationService.instance.token = ValueNotifier('asdf'); - - final newConnection = separateConnection() - ..prepare(json: {'msg': '', 'result': 'success'}); - final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); - await tester.pump(Duration.zero); - await future; - checkSingleUnregisterRequest(newConnection, expectedToken: 'asdf'); - check(newConnection.isOpen).isFalse(); - }); - - testWidgets('no error if acked token and current token both missing', (tester) async { - await prepare(tester, ackedPushToken: null); - NotificationService.instance.token = ValueNotifier(null); - - final newConnection = separateConnection(); - final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); - await tester.pumpAndSettle(); - await future; - check(newConnection.takeRequests()).isEmpty(); - }); - - testWidgets('connection closed if request errors', (tester) async { - await prepare(tester, ackedPushToken: '123'); - - final newConnection = separateConnection() - ..prepare(exception: ZulipApiException( - httpStatus: 401, - code: 'UNAUTHORIZED', - data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, - routeName: 'removeEtcEtcToken', - message: 'Invalid API key', - )); - final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); - await tester.pump(Duration.zero); - await future; - checkSingleUnregisterRequest(newConnection, expectedToken: '123'); - check(newConnection.isOpen).isFalse(); - }); - }); - group('markNarrowAsRead', () { testWidgets('smoke test on modern server', (tester) async { final narrow = TopicNarrow.ofMessage(eg.streamMessage()); diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index a599ad9d1a..1525355766 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/model/actions.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/about_zulip.dart'; -import 'package:zulip/widgets/actions.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/home.dart'; diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index a8274346a8..7e70f01a19 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -4,8 +4,8 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/actions.dart'; import 'package:zulip/model/store.dart'; -import 'package:zulip/widgets/actions.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/page.dart'; From a05548647550fdcc7304641db6dd6b683613f79a Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 11 Feb 2025 17:03:26 -0500 Subject: [PATCH 042/110] msglist: Ensure sole ownership of MessageListView `PerAccountStore` shouldn't be an owner of the `MessageListView` objects. Its relationship to `MessageListView` is similar to that of `AutocompleteViewManager` to `MentionAutocompleteView` (#645). With two owners, `MessageListView` can be disposed twice when `removeAccount` is called (when the user logs out, for example): 1. Before the frame is rendered, after removing the `PerAccountStore` from `GlobalStore`, `removeAccount` disposes the `PerAccountStore` , which disposes the `MessageListView` (via `MessageStoreImpl`). `removeAccount` also notifies the listeners of `GlobalStore`. At this point `_MessageListState` is not yet disposed. 2. Being dependent on `GlobalStore`, `PerAccountStoreWidget` is rebuilt. This time, the StatefulElement corresponding to the `MessageList` widget, is no longer in the element tree because `PerAccountStoreWidget` cannot find the account and builds a placeholder instead. 3. During finalization, `_MessageListState` tries to dispose the `MessageListView`, and fails to do so. We couldn't've kept `MessageStoreImpl.dispose` with an assertion `_messageListView.isEmpty`, because `PerAccountStore` is disposed before `_MessageListState.dispose` (and similarily `_MessageListState.onNewStore`) is called. Fixing that will be a future follow-up to this, as noted in the TODO comment. See discussion: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893 Signed-off-by: Zixuan James Li --- lib/model/message.dart | 20 +++++++++---- lib/widgets/message_list.dart | 1 + test/model/message_test.dart | 12 -------- test/model/store_test.dart | 21 -------------- test/widgets/message_list_test.dart | 45 +++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 39 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index f228d6da91..84f3bbc3e5 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -61,12 +61,20 @@ class MessageStoreImpl with MessageStore { } void dispose() { - // When a MessageListView is disposed, it removes itself from the Set - // `MessageStoreImpl._messageListViews`. Instead of iterating on that Set, - // iterate on a copy, to avoid concurrent modifications. - for (final view in _messageListViews.toList()) { - view.dispose(); - } + // Not disposing the [MessageListView]s here, because they are owned by + // (i.e., they get [dispose]d by) the [_MessageListState], including in the + // case where the [PerAccountStore] is replaced. + // + // TODO: Add assertions that the [MessageListView]s are indeed disposed, by + // first ensuring that [PerAccountStore] is only disposed after those with + // references to it are disposed, then reinstating this `dispose` method. + // + // We can't add the assertions as-is because the sequence of events + // guarantees that `PerAccountStore` is disposed (when that happens, + // [GlobalStore] notifies its listeners, causing widgets dependent on the + // [InheritedNotifier] to rebuild in the next frame) before the owner's + // `dispose` or `onNewStore` is called. Discussion: + // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893 } @override diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 57259640b1..4d0ff00072 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -483,6 +483,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages + model?.dispose(); _initModel(PerAccountStoreWidget.of(context)); } diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 43f17be61a..9c9a940d42 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -77,18 +77,6 @@ void main() { checkNotified(count: messageList.fetched ? messages.length : 0); } - test('disposing multiple registered MessageListView instances', () async { - // Regression test for: https://github.com/zulip/zulip-flutter/issues/810 - await prepare(narrow: const MentionsNarrow()); - MessageListView.init(store: store, narrow: const StarredMessagesNarrow()); - check(store.debugMessageListViews).length.equals(2); - - // When disposing, the [MessageListView]s are expected to unregister - // themselves from the message store. - store.dispose(); - check(store.debugMessageListViews).isEmpty(); - }); - group('reconcileMessages', () { test('from empty', () async { await prepare(); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index c8c6b4c266..bc393d6d6f 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -12,8 +12,6 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/events.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/realm.dart'; -import 'package:zulip/model/message_list.dart'; -import 'package:zulip/model/narrow.dart'; import 'package:zulip/log.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/receive.dart'; @@ -824,25 +822,6 @@ void main() { checkReload(prepareHandleEventError); }); - test('expired queue disposes registered MessageListView instances', () => awaitFakeAsync((async) async { - // Regression test for: https://github.com/zulip/zulip-flutter/issues/810 - await preparePoll(); - - // Make sure there are [MessageListView]s in the message store. - MessageListView.init(store: store, narrow: const MentionsNarrow()); - MessageListView.init(store: store, narrow: const StarredMessagesNarrow()); - check(store.debugMessageListViews).length.equals(2); - - // Let the server expire the event queue. - prepareExpiredEventQueue(); - updateMachine.debugAdvanceLoop(); - async.elapse(Duration.zero); - - // The old store's [MessageListView]s have been disposed. - // (And no exception was thrown; that was #810.) - check(store.debugMessageListViews).isEmpty(); - })); - group('report error', () { String? lastReportedError; String? takeLastReportedError() { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index b3cc208463..3f79f8cae6 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -7,11 +7,13 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -56,6 +58,7 @@ void main() { List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List navObservers = const [], + bool skipAssertAccountExists = false, }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); @@ -77,6 +80,7 @@ void main() { eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + skipAssertAccountExists: skipAssertAccountExists, navigatorObservers: navObservers, child: MessageListPage(initNarrow: narrow))); @@ -130,6 +134,47 @@ void main() { final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); check(state.composeBoxController).isNull(); }); + + testWidgets('dispose MessageListView when event queue expired', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, messages: [message]); + final oldViewModel = store.debugMessageListViews.single; + final updateMachine = store.updateMachine!; + updateMachine.debugPauseLoop(); + updateMachine.poll(); + + updateMachine.debugPrepareLoopError(ZulipApiException( + routeName: 'events', httpStatus: 400, code: 'BAD_EVENT_QUEUE_ID', + data: {'queue_id': updateMachine.queueId}, message: 'Bad event queue ID.')); + updateMachine.debugAdvanceLoop(); + await tester.pump(); + // Event queue has been replaced; but the [MessageList] hasn't been + // rebuilt yet. + final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!; + check(connection.isOpen).isFalse(); // indicates that the old store has been disposed + check(store.debugMessageListViews).single.equals(oldViewModel); + check(newStore.debugMessageListViews).isEmpty(); + + (newStore.connection as FakeApiConnection).prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pump(); + await tester.pump(Duration.zero); + // As [MessageList] rebuilds, the old view model gets disposed and + // replaced with a fresh one. + check(store.debugMessageListViews).isEmpty(); + check(newStore.debugMessageListViews).single.not((it) => it.equals(oldViewModel)); + }); + + testWidgets('dispose MessageListView when logged out', (tester) async { + await setupMessageListPage(tester, + messages: [eg.streamMessage()], skipAssertAccountExists: true); + check(store.debugMessageListViews).single; + + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + check(store.debugMessageListViews).isEmpty(); + }); }); group('app bar', () { From b8c26ec5d46d3c97cf6fdc3ad21e6dea1c7ae4d0 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 13 Feb 2025 23:21:30 -0800 Subject: [PATCH 043/110] doc: Update GSoC reference in README Just so there's no confusion about whether this is true again this year. (The substance of the project description lives at the linked page, in the zulip/zulip repo, and already got updated last week.) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1bcc1b00d2..ee6477dd67 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ You can also [try out this beta app][beta]. Contributions to this app are welcome. If you're looking to participate in Google Summer of Code with Zulip, -this is one of the projects we're [accepting GSoC 2024 applications][] +this is one of the projects we intend to accept [GSoC 2025 applications][gsoc] for. -[accepting GSoC 2024 applications]: https://zulip.readthedocs.io/en/latest/outreach/gsoc.html#mobile-app +[gsoc]: https://zulip.readthedocs.io/en/latest/outreach/gsoc.html#mobile-app ### Picking an issue to work on From 96bdc7dec036bad06c28411cf01f035c9b596751 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 31 Jan 2025 15:35:09 -0800 Subject: [PATCH 044/110] sticky_header [nfc]: Document SliverStickyHeaderList This is the class we actually use at this point -- not StickyHeaderListView -- so it's good for it to have some docs too. --- lib/widgets/sticky_header.dart | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 24eeb8d518..9dd0183920 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -99,6 +99,18 @@ class RenderStickyHeaderItem extends RenderProxyBox { /// or if [scrollDirection] is horizontal then to the start in the /// reading direction of the ambient [Directionality]. /// It can be controlled with [reverseHeader]. +/// +/// Much like [ListView], a [StickyHeaderListView] is basically +/// a [CustomScrollView] with a single sliver in its [CustomScrollView.slivers] +/// property. +/// For a [StickyHeaderListView], that sliver is a [SliverStickyHeaderList]. +/// +/// If more than one sliver is needed, any code using [StickyHeaderListView] +/// can be ported to use [CustomScrollView] directly, in much the same way +/// as for code using [ListView]. See [ListView] for details. +/// +/// See also: +/// * [SliverStickyHeaderList], which provides the sticky-header behavior. class StickyHeaderListView extends BoxScrollView { // Like ListView, but with sticky headers. StickyHeaderListView({ @@ -296,6 +308,10 @@ enum _HeaderGrowthPlacement { growthEnd } +/// A list sliver with sticky headers. +/// +/// This widget takes most of its behavior from [SliverList], +/// but adds sticky headers as described at [StickyHeaderListView]. class SliverStickyHeaderList extends RenderObjectWidget { SliverStickyHeaderList({ super.key, @@ -306,7 +322,16 @@ class SliverStickyHeaderList extends RenderObjectWidget { delegate: delegate, ); + /// Whether the sticky header appears at the start or the end + /// in the scrolling direction. + /// + /// For example, if the enclosing [Viewport] has [Viewport.axisDirection] + /// of [AxisDirection.down], then + /// [HeaderPlacement.scrollingStart] means the header appears at + /// the top of the viewport, and + /// [HeaderPlacement.scrollingEnd] means it appears at the bottom. final HeaderPlacement headerPlacement; + final _SliverStickyHeaderListInner _child; @override From 41c410cefefe167b0e4cea215473841fbff62f3b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 30 Jan 2025 18:29:54 -0800 Subject: [PATCH 045/110] sticky_header example: Enable ink splashes, to demo hit-testing --- lib/example/sticky_header.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart index 3fa2185c1a..feeb3181db 100644 --- a/lib/example/sticky_header.dart +++ b/lib/example/sticky_header.dart @@ -197,6 +197,7 @@ class WideHeader extends StatelessWidget { return Material( color: Theme.of(context).colorScheme.primaryContainer, child: ListTile( + onTap: () {}, // nop, but non-null so the ink splash appears title: Text("Section ${i + 1}", style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer)))); @@ -211,7 +212,9 @@ class WideItem extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile(title: Text("Item ${i + 1}.${j + 1}")); + return ListTile( + onTap: () {}, // nop, but non-null so the ink splash appears + title: Text("Item ${i + 1}.${j + 1}")); } } From 16308e535a72269af798e87c471a14db131ec4df Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 23 Jan 2025 21:43:32 -0800 Subject: [PATCH 046/110] sticky_header example: Set allowOverflow true in double-sliver example --- lib/example/sticky_header.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart index feeb3181db..a7b6e4167f 100644 --- a/lib/example/sticky_header.dart +++ b/lib/example/sticky_header.dart @@ -152,6 +152,7 @@ class ExampleVerticalDouble extends StatelessWidget { (context, i) { final ii = i + numBottomSections; return StickyHeaderItem( + allowOverflow: true, header: WideHeader(i: ii), child: Column( children: List.generate(numPerSection + 1, (j) { @@ -167,6 +168,7 @@ class ExampleVerticalDouble extends StatelessWidget { (context, i) { final ii = numBottomSections - 1 - i; return StickyHeaderItem( + allowOverflow: true, header: WideHeader(i: ii), child: Column( children: List.generate(numPerSection + 1, (j) { From 4ab8121a7918eba5e9b1d4fe4fdf749b59f02cdc Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 31 Jan 2025 16:43:26 -0800 Subject: [PATCH 047/110] sticky_header example: Make double slivers not back-to-back This way the example can be used to demonstrate the next cluster of bug fixes working correctly, before yet fixing the next issue after those. --- lib/example/sticky_header.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart index a7b6e4167f..1b55d04016 100644 --- a/lib/example/sticky_header.dart +++ b/lib/example/sticky_header.dart @@ -134,23 +134,20 @@ class ExampleVerticalDouble extends StatelessWidget { @override Widget build(BuildContext context) { - const centerSliverKey = ValueKey('center sliver'); - const numSections = 100; + const numSections = 4; const numBottomSections = 2; const numPerSection = 10; return Scaffold( appBar: AppBar(title: Text(title)), body: CustomScrollView( semanticChildCount: numSections, - anchor: 0.5, - center: centerSliverKey, slivers: [ SliverStickyHeaderList( headerPlacement: HeaderPlacement.scrollingStart, delegate: SliverChildBuilderDelegate( childCount: numSections - numBottomSections, (context, i) { - final ii = i + numBottomSections; + final ii = numSections - 1 - i; return StickyHeaderItem( allowOverflow: true, header: WideHeader(i: ii), @@ -161,7 +158,6 @@ class ExampleVerticalDouble extends StatelessWidget { }))); })), SliverStickyHeaderList( - key: centerSliverKey, headerPlacement: HeaderPlacement.scrollingStart, delegate: SliverChildBuilderDelegate( childCount: numBottomSections, From ca394c0fdfaa60342bd55fd1e7968c6ea87b55b9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 31 Jan 2025 13:50:26 -0800 Subject: [PATCH 048/110] sticky_header test: Favor drag gestures over taps, when they compete This is nearly NFC. The sense in which it isn't is that if any of the `tester.drag` steps touched a header -- which listens for tap gestures -- then this would ensure that step got interpreted as a drag. The old version would (a) interpret it as a tap, not a drag, if it was less than 20px in length; (b) leave those first 20px out of the effective length of the drag. The use of DragStartBehavior.down fixes (b). Then there are some drags of 5px in these tests, subject to (a); TouchSlop fixes those (by reducing the touch slop to 1px, instead of 20px). This change will be needed in order to have the item widgets start recording taps too, like the header widgets do, without messing up the drags in these tests. --- test/widgets/sticky_header_test.dart | 52 ++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index c283652ae1..13224fc68d 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:checks/checks.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/widgets/sticky_header.dart'; @@ -8,12 +9,14 @@ import 'package:zulip/widgets/sticky_header.dart'; void main() { testWidgets('sticky headers: scroll up, headers overflow items, explicit version', (tester) async { await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, - child: StickyHeaderListView( - reverse: true, - children: List.generate(100, (i) => StickyHeaderItem( - allowOverflow: true, - header: _Header(i, height: 20), - child: _Item(i, height: 100)))))); + child: TouchSlop(touchSlop: 1, + child: StickyHeaderListView( + dragStartBehavior: DragStartBehavior.down, + reverse: true, + children: List.generate(100, (i) => StickyHeaderItem( + allowOverflow: true, + header: _Header(i, height: 20), + child: _Item(i, height: 100))))))); check(_itemIndexes(tester)).deepEquals([0, 1, 2, 3, 4, 5]); check(_headerIndex(tester)).equals(5); check(tester.getTopLeft(find.byType(_Item).last)).equals(const Offset(0, 0)); @@ -43,11 +46,13 @@ void main() { testWidgets('sticky headers: scroll up, headers bounded by items, semi-explicit version', (tester) async { await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, - child: StickyHeaderListView( - reverse: true, - children: List.generate(100, (i) => StickyHeaderItem( - header: _Header(i, height: 20), - child: _Item(i, height: 100)))))); + child: TouchSlop(touchSlop: 1, + child: StickyHeaderListView( + dragStartBehavior: DragStartBehavior.down, + reverse: true, + children: List.generate(100, (i) => StickyHeaderItem( + header: _Header(i, height: 20), + child: _Item(i, height: 100))))))); void checkState(int index, {required double item, required double header}) => _checkHeader(tester, index, first: false, @@ -108,6 +113,7 @@ void main() { Widget page(Widget Function(BuildContext, int) itemBuilder) { return Directionality(textDirection: TextDirection.ltr, child: StickyHeaderListView.builder( + dragStartBehavior: DragStartBehavior.down, cacheExtent: 0, itemCount: 10, itemBuilder: itemBuilder)); } @@ -386,3 +392,27 @@ class _Item extends StatelessWidget { child: Text("Item $index")); } } + + +/// Sets [DeviceGestureSettings.touchSlop] for the child subtree +/// to the given value, by inserting a [MediaQuery]. +/// +/// For example `TouchSlop(touchSlop: 1, …)` means a touch that moves by even +/// a single pixel will be interpreted as a drag, even if a tap gesture handler +/// is competing for the gesture. For human fingers that'd make it unreasonably +/// difficult to make a tap, but in a test carried out by software it can be +/// convenient for making small drag gestures straightforward. +class TouchSlop extends StatelessWidget { + const TouchSlop({super.key, required this.touchSlop, required this.child}); + + final double touchSlop; + final Widget child; + + @override + Widget build(BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + gestureSettings: DeviceGestureSettings(touchSlop: touchSlop)), + child: child); + } +} From 63f66d1a88b4a0628db197de7d1458b8f1f99f0f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 31 Jan 2025 13:38:21 -0800 Subject: [PATCH 049/110] sticky_header test [nfc]: Generalize tap-logging from headers --- test/widgets/sticky_header_test.dart | 32 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index 13224fc68d..b4795b1d62 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -304,10 +305,11 @@ Future _checkSequence( // Check the header gets hit when it should, and not when it shouldn't. await tester.tapAt(headerInset(1)); await tester.tapAt(headerInset(expectedHeaderInsetExtent - 1)); - check(_Header.takeTapCount()).equals(2); + check(_TapLogged.takeTapLog())..length.equals(2) + ..every((it) => it.isA<_Header>()); await tester.tapAt(headerInset(extent - 1)); await tester.tapAt(headerInset(extent - (expectedHeaderInsetExtent - 1))); - check(_Header.takeTapCount()).equals(0); + check(_TapLogged.takeTapLog()).isEmpty(); } Future jumpAndCheck(double position) async { @@ -354,28 +356,36 @@ Iterable _itemIndexes(WidgetTester tester) { return tester.widgetList<_Item>(find.byType(_Item)).map((w) => w.index); } -class _Header extends StatelessWidget { +sealed class _TapLogged { + static List<_TapLogged> takeTapLog() { + final result = _tapLog; + _tapLog = []; + return result; + } + static List<_TapLogged> _tapLog = []; +} + +class _Header extends StatelessWidget implements _TapLogged { const _Header(this.index, {required this.height}); final int index; final double height; - static int takeTapCount() { - final result = _tapCount; - _tapCount = 0; - return result; - } - static int _tapCount = 0; - @override Widget build(BuildContext context) { return SizedBox( height: height, width: height, // TODO clean up child: GestureDetector( - onTap: () => _tapCount++, + onTap: () => _TapLogged._tapLog.add(this), child: Text("Header $index"))); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('index', index)); + } } class _Item extends StatelessWidget { From 8bf8c2476c30dcf86c78a55cec0733333049cbe6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 31 Jan 2025 13:56:27 -0800 Subject: [PATCH 050/110] sticky_header test: Record taps on _Item widgets too This will be useful in a test we'll add for an upcoming change. --- test/widgets/sticky_header_test.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index b4795b1d62..4f6c8ed071 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -309,7 +309,8 @@ Future _checkSequence( ..every((it) => it.isA<_Header>()); await tester.tapAt(headerInset(extent - 1)); await tester.tapAt(headerInset(extent - (expectedHeaderInsetExtent - 1))); - check(_TapLogged.takeTapLog()).isEmpty(); + check(_TapLogged.takeTapLog())..length.equals(2) + ..every((it) => it.isA<_Item>()); } Future jumpAndCheck(double position) async { @@ -388,7 +389,7 @@ class _Header extends StatelessWidget implements _TapLogged { } } -class _Item extends StatelessWidget { +class _Item extends StatelessWidget implements _TapLogged { const _Item(this.index, {required this.height}); final int index; @@ -399,10 +400,17 @@ class _Item extends StatelessWidget { return SizedBox( height: height, width: height, - child: Text("Item $index")); + child: GestureDetector( + onTap: () => _TapLogged._tapLog.add(this), + child: Text("Item $index"))); } -} + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('index', index)); + } +} /// Sets [DeviceGestureSettings.touchSlop] for the child subtree /// to the given value, by inserting a [MediaQuery]. From b541ea3ad8b5ab7f08221c30cee92d5a4b339090 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 7 Feb 2025 21:17:06 -0800 Subject: [PATCH 051/110] sticky_header example: Add double slivers with header at bottom These changes are NFC for the existing double-sliver example, with the sticky header at the top. --- lib/example/sticky_header.dart | 52 +++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart index 1b55d04016..b1dadd479e 100644 --- a/lib/example/sticky_header.dart +++ b/lib/example/sticky_header.dart @@ -125,40 +125,61 @@ class ExampleVerticalDouble extends StatelessWidget { super.key, required this.title, // this.reverse = false, - // this.headerDirection = AxisDirection.down, - }); // : assert(axisDirectionToAxis(headerDirection) == Axis.vertical); + required this.headerPlacement, + }); final String title; // final bool reverse; - // final AxisDirection headerDirection; + final HeaderPlacement headerPlacement; @override Widget build(BuildContext context) { const numSections = 4; const numBottomSections = 2; + const numTopSections = numSections - numBottomSections; const numPerSection = 10; + + final headerAtBottom = switch (headerPlacement) { + HeaderPlacement.scrollingStart => false, + HeaderPlacement.scrollingEnd => true, + }; + + // Choose the "center" sliver so that the sliver which might need to paint + // a header overflowing the other header is the sliver that paints last. + final centerKey = headerAtBottom ? + const ValueKey('bottom') : const ValueKey('top'); + + // This is a side effect of our choice of centerKey. + final topSliverGrowsUpward = headerAtBottom; + return Scaffold( appBar: AppBar(title: Text(title)), body: CustomScrollView( semanticChildCount: numSections, + center: centerKey, slivers: [ SliverStickyHeaderList( - headerPlacement: HeaderPlacement.scrollingStart, + key: const ValueKey('top'), + headerPlacement: headerPlacement, delegate: SliverChildBuilderDelegate( childCount: numSections - numBottomSections, (context, i) { - final ii = numSections - 1 - i; + final ii = numBottomSections + + (topSliverGrowsUpward ? i : numTopSections - 1 - i); return StickyHeaderItem( allowOverflow: true, header: WideHeader(i: ii), child: Column( + verticalDirection: headerAtBottom + ? VerticalDirection.up : VerticalDirection.down, children: List.generate(numPerSection + 1, (j) { if (j == 0) return WideHeader(i: ii); return WideItem(i: ii, j: j-1); }))); })), SliverStickyHeaderList( - headerPlacement: HeaderPlacement.scrollingStart, + key: const ValueKey('bottom'), + headerPlacement: headerPlacement, delegate: SliverChildBuilderDelegate( childCount: numBottomSections, (context, i) { @@ -167,10 +188,12 @@ class ExampleVerticalDouble extends StatelessWidget { allowOverflow: true, header: WideHeader(i: ii), child: Column( + verticalDirection: headerAtBottom + ? VerticalDirection.up : VerticalDirection.down, children: List.generate(numPerSection + 1, (j) { - if (j == 0) return WideHeader(i: ii); - return WideItem(i: ii, j: j-1); - }))); + if (j == 0) return WideHeader(i: ii); + return WideItem(i: ii, j: j-1); + }))); })), ])); } @@ -319,8 +342,15 @@ class MainPage extends StatelessWidget { ]; final otherItems = [ _buildButton(context, - title: 'Double slivers', - page: ExampleVerticalDouble(title: 'Double slivers')), + title: 'Double slivers, headers at top', + page: ExampleVerticalDouble( + title: 'Double slivers, headers at top', + headerPlacement: HeaderPlacement.scrollingStart)), + _buildButton(context, + title: 'Double slivers, headers at bottom', + page: ExampleVerticalDouble( + title: 'Double slivers, headers at bottom', + headerPlacement: HeaderPlacement.scrollingEnd)), ]; return Scaffold( appBar: AppBar(title: const Text('Sticky Headers example')), From 628ac15f023a1a5e1cf087f4d98ec1693d6292d2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 10 Feb 2025 22:10:00 -0800 Subject: [PATCH 052/110] sticky_header test [nfc]: Make "first/last item" finders more robust The existing `.first` and `.last` versions rely on the order that children appear in the Flutter element tree. As is, that works because the `_Item` widgets are all children of one list sliver, and it manages its children in a nice straightforward order. But when we add multiple list slivers, the order will depend also on the order those appear as children of the viewport; and in particular the items at the edges of the viewport will no longer always be the first or last items in the tree. So introduce a couple of custom finders to keep finding the first or last items in the sense we mean. For examples of using the APIs these finders use, compare the implementation of [FinderBase.first]. --- test/widgets/sticky_header_test.dart | 63 +++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index 4f6c8ed071..422debc825 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -277,9 +277,10 @@ Future _checkSequence( final first = !(reverse ^ reverseHeader ^ reverseGrowth); - final itemFinder = first ? find.byType(_Item).first : find.byType(_Item).last; + final itemFinder = first ? _LeastItemFinder(find.byType(_Item)) + : _GreatestItemFinder(find.byType(_Item)); - double insetExtent(Finder finder) { + double insetExtent(FinderBase finder) { return headerAtCoordinateEnd ? extent - tester.getTopLeft(finder).inDirection(axis.coordinateDirection) : tester.getBottomRight(finder).inDirection(axis.coordinateDirection); @@ -330,6 +331,64 @@ Future _checkSequence( await jumpAndCheck(100); } +abstract class _SelectItemFinder extends FinderBase with ChainedFinderMixin { + bool shouldPrefer(_Item candidate, _Item previous); + + @override + Iterable filter(Iterable parentCandidates) { + Element? result; + _Item? resultWidget; + for (final candidate in parentCandidates) { + if (candidate is! ComponentElement) continue; + final widget = candidate.widget; + if (widget is! _Item) continue; + if (resultWidget == null || shouldPrefer(widget, resultWidget)) { + result = candidate; + resultWidget = widget; + } + } + return [if (result != null) result]; + } +} + +/// Finds the [_Item] with least [_Item.index] +/// out of all elements found by the given parent finder. +class _LeastItemFinder extends _SelectItemFinder { + _LeastItemFinder(this.parent); + + @override + final FinderBase parent; + + @override + String describeMatch(Plurality plurality) { + return 'least-index _Item from ${parent.describeMatch(plurality)}'; + } + + @override + bool shouldPrefer(_Item candidate, _Item previous) { + return candidate.index < previous.index; + } +} + +/// Finds the [_Item] with greatest [_Item.index] +/// out of all elements found by the given parent finder. +class _GreatestItemFinder extends _SelectItemFinder { + _GreatestItemFinder(this.parent); + + @override + final FinderBase parent; + + @override + String describeMatch(Plurality plurality) { + return 'greatest-index _Item from ${parent.describeMatch(plurality)}'; + } + + @override + bool shouldPrefer(_Item candidate, _Item previous) { + return candidate.index > previous.index; + } +} + Future _drag(WidgetTester tester, Offset offset) async { await tester.drag(find.byType(StickyHeaderListView), offset); await tester.pump(); From 488c60c38833027c357294a0aa3d227339644401 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 10 Feb 2025 19:26:17 -0800 Subject: [PATCH 053/110] sticky_header test [nfc]: Prepare generic test for more generality --- test/widgets/sticky_header_test.dart | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index 422debc825..fc9a38c970 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -237,6 +237,15 @@ Future _checkSequence( Axis.vertical => reverseHeader, }; final reverseGrowth = (growthDirection == GrowthDirection.reverse); + final headerPlacement = reverseHeader ^ reverse + ? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart; + + Widget buildItem(int i) { + return StickyHeaderItem( + allowOverflow: allowOverflow, + header: _Header(i, height: 20), + child: _Item(i, height: 100)); + } final controller = ScrollController(); const listKey = ValueKey("list"); @@ -252,13 +261,9 @@ Future _checkSequence( slivers: [ SliverStickyHeaderList( key: listKey, - headerPlacement: (reverseHeader ^ reverse) - ? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart, + headerPlacement: headerPlacement, delegate: SliverChildListDelegate( - List.generate(100, (i) => StickyHeaderItem( - allowOverflow: allowOverflow, - header: _Header(i, height: 20), - child: _Item(i, height: 100))))), + List.generate(100, (i) => buildItem(i)))), const SliverPadding( key: emptyKey, padding: EdgeInsets.zero), @@ -315,7 +320,8 @@ Future _checkSequence( } Future jumpAndCheck(double position) async { - controller.jumpTo(position * (reverseGrowth ? -1 : 1)); + final scrollPosition = position * (reverseGrowth ? -1 : 1); + controller.jumpTo(scrollPosition); await tester.pump(); await checkState(); } From 7df2c378d563f0739ba89316e33eda0ccf207e1a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 10 Feb 2025 19:35:37 -0800 Subject: [PATCH 054/110] sticky_header test [nfc]: Prepare list of slivers more uniformly This will be helpful for keeping complexity down when we add more slivers to this list. --- test/widgets/sticky_header_test.dart | 37 +++++++++++++++++----------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index fc9a38c970..ac7c4270ce 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; @@ -247,27 +248,35 @@ Future _checkSequence( child: _Item(i, height: 100)); } + const center = ValueKey("center"); + final slivers = [ + const SliverPadding( + key: center, + padding: EdgeInsets.zero), + SliverStickyHeaderList( + headerPlacement: headerPlacement, + delegate: SliverChildListDelegate( + List.generate(100, (i) => buildItem(i)))), + ]; + + final double anchor; + if (reverseGrowth) { + slivers.reverseRange(0, slivers.length); + anchor = 1.0; + } else { + anchor = 0.0; + } + final controller = ScrollController(); - const listKey = ValueKey("list"); - const emptyKey = ValueKey("empty"); await tester.pumpWidget(Directionality( textDirection: textDirection ?? TextDirection.rtl, child: CustomScrollView( controller: controller, scrollDirection: axis, reverse: reverse, - anchor: reverseGrowth ? 1.0 : 0.0, - center: reverseGrowth ? emptyKey : listKey, - slivers: [ - SliverStickyHeaderList( - key: listKey, - headerPlacement: headerPlacement, - delegate: SliverChildListDelegate( - List.generate(100, (i) => buildItem(i)))), - const SliverPadding( - key: emptyKey, - padding: EdgeInsets.zero), - ]))); + anchor: anchor, + center: center, + slivers: slivers))); final overallSize = tester.getSize(find.byType(CustomScrollView)); final extent = overallSize.onAxis(axis); From fba97d8d5cf1247152e7b10545ae99467cb78d8b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 10 Feb 2025 19:42:46 -0800 Subject: [PATCH 055/110] sticky_header test: Use 10 items instead of 100 This is still enough to fill more than the viewport, and will be more helpful for scrolling past an entire sliver when we add more slivers. --- test/widgets/sticky_header_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index ac7c4270ce..82c020a861 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -256,7 +256,7 @@ Future _checkSequence( SliverStickyHeaderList( headerPlacement: headerPlacement, delegate: SliverChildListDelegate( - List.generate(100, (i) => buildItem(i)))), + List.generate(10, (i) => buildItem(i)))), ]; final double anchor; From f53521be182efc5602b5bd0e4831344734129705 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 10 Feb 2025 19:49:13 -0800 Subject: [PATCH 056/110] sticky_header test: Test slivers splitting viewport There are still some bugs affecting the sticky_header library when a SliverStickyHeaderList occupies only part of the viewport (which is a configuration we'll need for letting the message list grow in both directions, for #82). I sent a PR which aimed to fix a cluster of those, in which I tried to get away without writing these systematic test cases for them. It worked for the cases I did test -- including the cases that would actually arise for the Zulip message list -- and I believed the changes were correct when I sent the PR. But that version was still conceptually confused, as evidenced by the fact that it turned out to break other cases: https://github.com/zulip/zulip-flutter/pull/1316#discussion_r1947339710 So that seems like a sign that this really should get systematic all-cases tests. Some of these new test cases don't yet work properly, because they exercise the aforementioned bugs. The "header overflowing sliver" skip condition will be removed later in this series. The "paint order" skips will be addressed in an upcoming PR. --- test/widgets/sticky_header_test.dart | 169 ++++++++++++++++++++++----- 1 file changed, 139 insertions(+), 30 deletions(-) diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index 82c020a861..e4bfdca515 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -4,6 +4,7 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/widgets/sticky_header.dart'; @@ -75,36 +76,42 @@ void main() { for (final reverse in [true, false]) { for (final reverseHeader in [true, false]) { for (final growthDirection in GrowthDirection.values) { - for (final allowOverflow in [true, false]) { - final name = 'sticky headers: ' - 'scroll ${reverse ? 'up' : 'down'}, ' - 'header at ${reverseHeader ? 'bottom' : 'top'}, ' - '$growthDirection, ' - 'headers ${allowOverflow ? 'overflow' : 'bounded'}'; - testWidgets(name, (tester) => - _checkSequence(tester, - Axis.vertical, - reverse: reverse, - reverseHeader: reverseHeader, - growthDirection: growthDirection, - allowOverflow: allowOverflow, - )); - - for (final textDirection in TextDirection.values) { + for (final sliverConfig in _SliverConfig.values) { + for (final allowOverflow in [true, false]) { final name = 'sticky headers: ' - '${textDirection.name.toUpperCase()} ' - 'scroll ${reverse ? 'backward' : 'forward'}, ' - 'header at ${reverseHeader ? 'end' : 'start'}, ' + 'scroll ${reverse ? 'up' : 'down'}, ' + 'header at ${reverseHeader ? 'bottom' : 'top'}, ' '$growthDirection, ' - 'headers ${allowOverflow ? 'overflow' : 'bounded'}'; + 'headers ${allowOverflow ? 'overflow' : 'bounded'}, ' + 'slivers ${sliverConfig.name}'; testWidgets(name, (tester) => _checkSequence(tester, - Axis.horizontal, textDirection: textDirection, + Axis.vertical, reverse: reverse, reverseHeader: reverseHeader, growthDirection: growthDirection, allowOverflow: allowOverflow, + sliverConfig: sliverConfig, )); + + for (final textDirection in TextDirection.values) { + final name = 'sticky headers: ' + '${textDirection.name.toUpperCase()} ' + 'scroll ${reverse ? 'backward' : 'forward'}, ' + 'header at ${reverseHeader ? 'end' : 'start'}, ' + '$growthDirection, ' + 'headers ${allowOverflow ? 'overflow' : 'bounded'}, ' + 'slivers ${sliverConfig.name}'; + testWidgets(name, (tester) => + _checkSequence(tester, + Axis.horizontal, textDirection: textDirection, + reverse: reverse, + reverseHeader: reverseHeader, + growthDirection: growthDirection, + allowOverflow: allowOverflow, + sliverConfig: sliverConfig, + )); + } } } } @@ -223,6 +230,12 @@ void main() { }); } +enum _SliverConfig { + single, + backToBack, + followed, +} + Future _checkSequence( WidgetTester tester, Axis axis, { @@ -231,6 +244,7 @@ Future _checkSequence( bool reverseHeader = false, GrowthDirection growthDirection = GrowthDirection.forward, required bool allowOverflow, + _SliverConfig sliverConfig = _SliverConfig.single, }) async { assert(textDirection != null || axis == Axis.vertical); final headerAtCoordinateEnd = switch (axis) { @@ -241,6 +255,19 @@ Future _checkSequence( final headerPlacement = reverseHeader ^ reverse ? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart; + if (allowOverflow + && ((sliverConfig == _SliverConfig.backToBack + && (reverse ^ reverseHeader)) + || (sliverConfig == _SliverConfig.followed + && (reverse ^ reverseHeader ^ !reverseGrowth)))) { + // (The condition for this skip is pretty complicated; it's just the + // conditions where the bug gets triggered, and I haven't tried to + // work through why this exact set of cases is what's affected. + // The important thing is they all get fixed in an upcoming commit.) + markTestSkipped('bug in header overflowing sliver'); // TODO fix + return; + } + Widget buildItem(int i) { return StickyHeaderItem( allowOverflow: allowOverflow, @@ -248,8 +275,14 @@ Future _checkSequence( child: _Item(i, height: 100)); } + const sliverScrollExtent = 1000; const center = ValueKey("center"); final slivers = [ + if (sliverConfig == _SliverConfig.backToBack) + SliverStickyHeaderList( + headerPlacement: headerPlacement, + delegate: SliverChildListDelegate( + List.generate(10, (i) => buildItem(-i - 1)))), const SliverPadding( key: center, padding: EdgeInsets.zero), @@ -257,16 +290,45 @@ Future _checkSequence( headerPlacement: headerPlacement, delegate: SliverChildListDelegate( List.generate(10, (i) => buildItem(i)))), + if (sliverConfig == _SliverConfig.followed) + SliverStickyHeaderList( + headerPlacement: headerPlacement, + delegate: SliverChildListDelegate( + List.generate(10, (i) => buildItem(i + 10)))), ]; final double anchor; + bool paintOrderGood; if (reverseGrowth) { slivers.reverseRange(0, slivers.length); anchor = 1.0; + paintOrderGood = switch (sliverConfig) { + _SliverConfig.single => true, + // The last sliver will paint last. + _SliverConfig.backToBack => headerPlacement == HeaderPlacement.scrollingEnd, + // The last sliver will paint last. + _SliverConfig.followed => headerPlacement == HeaderPlacement.scrollingEnd, + }; } else { anchor = 0.0; + paintOrderGood = switch (sliverConfig) { + _SliverConfig.single => true, + // The last sliver will paint last. + _SliverConfig.backToBack => headerPlacement == HeaderPlacement.scrollingEnd, + // The first sliver will paint last. + _SliverConfig.followed => headerPlacement == HeaderPlacement.scrollingStart, + }; + } + + final skipBecausePaintOrder = allowOverflow && !paintOrderGood; + if (skipBecausePaintOrder) { + // TODO need to control paint order of slivers within viewport in order to + // make some configurations behave properly when headers overflow slivers + markTestSkipped('sliver paint order'); + // Don't return yet; we'll still check layout, and skip specific affected checks below. } + final controller = ScrollController(); await tester.pumpWidget(Directionality( textDirection: textDirection ?? TextDirection.rtl, @@ -281,6 +343,7 @@ Future _checkSequence( final overallSize = tester.getSize(find.byType(CustomScrollView)); final extent = overallSize.onAxis(axis); assert(extent % 100 == 0); + assert(sliverScrollExtent - extent > 100); // A position `inset` from the center of the edge the header is found on. Offset headerInset(double inset) { @@ -318,6 +381,7 @@ Future _checkSequence( check(insetExtent(find.byType(_Header))).equals(expectedHeaderInsetExtent); // Check the header gets hit when it should, and not when it shouldn't. + if (skipBecausePaintOrder) return; await tester.tapAt(headerInset(1)); await tester.tapAt(headerInset(expectedHeaderInsetExtent - 1)); check(_TapLogged.takeTapLog())..length.equals(2) @@ -335,15 +399,60 @@ Future _checkSequence( await checkState(); } - await checkState(); - await jumpAndCheck(5); - await jumpAndCheck(10); - await jumpAndCheck(20); - await jumpAndCheck(50); - await jumpAndCheck(80); - await jumpAndCheck(90); - await jumpAndCheck(95); - await jumpAndCheck(100); + Future checkLocally() async { + final scrollOffset = controller.position.pixels * (reverseGrowth ? -1 : 1); + await checkState(); + await jumpAndCheck(scrollOffset + 5); + await jumpAndCheck(scrollOffset + 10); + await jumpAndCheck(scrollOffset + 20); + await jumpAndCheck(scrollOffset + 50); + await jumpAndCheck(scrollOffset + 80); + await jumpAndCheck(scrollOffset + 90); + await jumpAndCheck(scrollOffset + 95); + await jumpAndCheck(scrollOffset + 100); + } + + Iterable listExtents() { + final result = tester.renderObjectList(find.byType(SliverStickyHeaderList, skipOffstage: false)) + .map((renderObject) => (renderObject as RenderSliver) + .geometry!.layoutExtent); + return reverseGrowth ? result.toList().reversed : result; + } + + switch (sliverConfig) { + case _SliverConfig.single: + // Just check the first header, at a variety of offsets, + // and check it hands off to the next header. + await checkLocally(); + + case _SliverConfig.followed: + // Check behavior as the next sliver scrolls into view. + await jumpAndCheck(sliverScrollExtent - extent); + check(listExtents()).deepEquals([extent, 0]); + await checkLocally(); + check(listExtents()).deepEquals([extent - 100, 100]); + + // Check behavior as the original sliver scrolls out of view. + await jumpAndCheck(sliverScrollExtent - 100); + check(listExtents()).deepEquals([100, extent - 100]); + await checkLocally(); + check(listExtents()).deepEquals([0, extent]); + + case _SliverConfig.backToBack: + // Scroll the other sliver into view; + // check behavior as it scrolls back out. + await jumpAndCheck(-100); + check(listExtents()).deepEquals([100, extent - 100]); + await checkLocally(); + check(listExtents()).deepEquals([0, extent]); + + // Scroll the original sliver out of view; + // check behavior as it scrolls back in. + await jumpAndCheck(-extent); + check(listExtents()).deepEquals([extent, 0]); + await checkLocally(); + check(listExtents()).deepEquals([extent - 100, 100]); + } } abstract class _SelectItemFinder extends FinderBase with ChainedFinderMixin { From d98b67b26fa682b70cf378d7295ba0b9ca6331d9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 7 Feb 2025 22:00:21 -0800 Subject: [PATCH 057/110] sticky_header [nfc]: Fix childMainAxisPosition to properly use paintExtent This fixes a latent bug: this method would give wrong answers if the sliver's paintExtent differed from its layoutExtent. The bug is latent because performLayout currently always produces a layoutExtent equal to paintExtent. But we'll start making them differ soon, as part of making hit-testing work correctly when a sticky header is painted by one sliver but needs to encroach on the layout area of another sliver. The framework calls this method as part of hit-testing, so that requires fixing this bug too. --- lib/widgets/sticky_header.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 9dd0183920..44b61fbcf1 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -691,16 +691,21 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper double childMainAxisPosition(RenderObject child) { if (child == this.child) return 0.0; assert(child == header); + + final headerParentData = (header!.parentData as SliverPhysicalParentData); + final paintOffset = headerParentData.paintOffset; + // We use Sliver*Physical*ParentData, so the header's position is stored in // physical coordinates. To meet the spec of `childMainAxisPosition`, we // need to convert to the sliver's coordinate system. - final headerParentData = (header!.parentData as SliverPhysicalParentData); - final paintOffset = headerParentData.paintOffset; + // This is all a bit silly because callers like [hitTestBoxChild] are just + // going to do the same things in reverse to get physical coordinates. + // Ah well; that's the API. return switch (constraints.growthAxisDirection) { AxisDirection.right => paintOffset.dx, - AxisDirection.left => geometry!.layoutExtent - header!.size.width - paintOffset.dx, + AxisDirection.left => geometry!.paintExtent - header!.size.width - paintOffset.dx, AxisDirection.down => paintOffset.dy, - AxisDirection.up => geometry!.layoutExtent - header!.size.height - paintOffset.dy, + AxisDirection.up => geometry!.paintExtent - header!.size.height - paintOffset.dy, }; } From 4cc3cb167d77925eb5de29d12120453c9cd720c4 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 11 Feb 2025 13:52:59 -0800 Subject: [PATCH 058/110] sticky_header [nfc]: Split header-overflows-sliver condition explicitly This makes for fewer situations to think about at a given point in the code, and will make the logic a bit easier to follow when we make some corrections to the overflow case. --- lib/widgets/sticky_header.dart | 50 +++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 44b61fbcf1..7e8464bca1 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -617,26 +617,38 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper // even if the (visible part of the) item is smaller than the header, // and even if the whole child sliver is smaller than the header. - final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); - geometry = SliverGeometry( // TODO review interaction with other slivers - scrollExtent: geometry.scrollExtent, - layoutExtent: childExtent, - paintExtent: math.max(childExtent, paintedHeaderSize), - maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), - hasVisualOverflow: geometry.hasVisualOverflow - || headerExtent > constraints.remainingPaintExtent, - - // The cache extent is an extension of layout, not paint; it controls - // where the next sliver should start laying out content. (See - // [SliverConstraints.remainingCacheExtent].) The header isn't meant - // to affect where the next sliver gets laid out, so it shouldn't - // affect the cache extent. - cacheExtent: geometry.cacheExtent, - ); + if (headerExtent <= childExtent) { + // The header fits within the child sliver. + // So it doesn't affect this sliver's overall geometry. - headerOffset = _headerAtCoordinateEnd() - ? childExtent - headerExtent - : 0.0; + headerOffset = _headerAtCoordinateEnd() + ? childExtent - headerExtent + : 0.0; + } else { + // The header will overflow the child sliver. + // That makes this sliver's geometry a bit more complicated. + + final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); + geometry = SliverGeometry( // TODO review interaction with other slivers + scrollExtent: geometry.scrollExtent, + layoutExtent: childExtent, + paintExtent: math.max(childExtent, paintedHeaderSize), + maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), + hasVisualOverflow: geometry.hasVisualOverflow + || headerExtent > constraints.remainingPaintExtent, + + // The cache extent is an extension of layout, not paint; it controls + // where the next sliver should start laying out content. (See + // [SliverConstraints.remainingCacheExtent].) The header isn't meant + // to affect where the next sliver gets laid out, so it shouldn't + // affect the cache extent. + cacheExtent: geometry.cacheExtent, + ); + + headerOffset = _headerAtCoordinateEnd() + ? childExtent - headerExtent + : 0.0; + } } else { // The header's item has [StickyHeaderItem.allowOverflow] false. // Keep the header within the item, pushing the header partly out of From afeacd5a65af39893b2e4fbd89183a2a1e8c4540 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 31 Jan 2025 14:15:11 -0800 Subject: [PATCH 059/110] sticky_header: Cut wrong use of calculatePaintOffset This commit is NFC for the actual app, or at least nearly so. This call to calculatePaintOffset was conceptually wrong: it's asking how much of this sliver's region to be painted is within the range of scroll offsets from zero to headerExtent. That'd be a pertinent question if we were locating something in that range of scroll offsets... but that range is not at all where the header goes, unless by happenstance. So the value returned is meaningless. One reason this buggy line has survived is that the bug is largely latent -- we can remove it entirely, as in this commit, and get exactly the same behavior except in odd circumstances. Specifically: * This paintedHeaderSize variable can only have any effect by being greater than childExtent. * In this case childExtent is smaller than headerExtent, too. * The main way that childExtent can be so small is if remainingPaintExtent, which constrains it, is equally small. * But calculatePaintOffset constrains its result, aka paintedHeaderSize, to at most remainingPaintExtent too, so then paintedHeaderSize still won't exceed childExtent. I say "main way" because the alternative is for the child to run out of content before finding as much as headerExtent of content to show. That could happen if the list just has less than that much content; but that means the header's own item is smaller than the header, which is a case that sticky_header doesn't really support well anyway and we don't have in the app. Otherwise, this would have to mean that some of the content was scrolled out of the viewport and then the child ran out of content before filling its allotted remainingPaintExtent of the viewport (and indeed before even reaching a headerExtent amount of content). This is actually not quite impossible, if the scrollable permits overscroll... but making it happen would require piling edge case upon edge case. Anyway, this call never made sense, so remove it. The resulting code in this headerExtent > childExtent case still isn't right. Removing this wrong logic helps clear the ground for fixing that. --- lib/widgets/sticky_header.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 7e8464bca1..d2516fb02c 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -628,11 +628,10 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper // The header will overflow the child sliver. // That makes this sliver's geometry a bit more complicated. - final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent); geometry = SliverGeometry( // TODO review interaction with other slivers scrollExtent: geometry.scrollExtent, layoutExtent: childExtent, - paintExtent: math.max(childExtent, paintedHeaderSize), + paintExtent: childExtent, maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), hasVisualOverflow: geometry.hasVisualOverflow || headerExtent > constraints.remainingPaintExtent, From 732761f6da2d0fc54de69fa59a882eb60d878727 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 7 Feb 2025 21:45:55 -0800 Subject: [PATCH 060/110] sticky_header [nfc]: Expand on the header-overflows-sliver case This case has several bugs in it. Not coincidentally, it's tricky to think through: there are several sub-cases and variables involved (the growth direction, vs. the header-placement direction, vs. the coordinate direction, ...). And in fact my original PR revision which fixed the cases that would affect the Zulip message list was still conceptually confused, as evidenced by the fact that it turned out to break other cases: https://github.com/zulip/zulip-flutter/pull/1316#discussion_r1947339710 One step in sorting that out was the preceding commit which split this overflows-sliver case from the alternative. As a next step, let's expand on the reasoning here a bit, with named variables and comments. In doing so, it becomes more apparent that several points in this calculation are wrong; for this NFC commit, mark those with TODO-comments. We'll fix them shortly. --- lib/widgets/sticky_header.dart | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index d2516fb02c..e5bb29b4a7 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -628,13 +628,28 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper // The header will overflow the child sliver. // That makes this sliver's geometry a bit more complicated. + // This sliver's paint region consists entirely of the header. + final paintExtent = headerExtent; + headerOffset = _headerAtCoordinateEnd() + ? childExtent - headerExtent // TODO buggy, should be zero + : 0.0; + + // Its layout region (affecting where the next sliver begins layout) + // is that given by the child sliver. + final layoutExtent = childExtent; + + // The paint origin places this sliver's paint region relative to its + // layout region. + final paintOrigin = 0.0; // TODO buggy + geometry = SliverGeometry( // TODO review interaction with other slivers scrollExtent: geometry.scrollExtent, - layoutExtent: childExtent, - paintExtent: childExtent, - maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent), + layoutExtent: layoutExtent, + paintExtent: childExtent, // TODO buggy + paintOrigin: paintOrigin, + maxPaintExtent: math.max(geometry.maxPaintExtent, paintExtent), hasVisualOverflow: geometry.hasVisualOverflow - || headerExtent > constraints.remainingPaintExtent, + || paintExtent > constraints.remainingPaintExtent, // The cache extent is an extension of layout, not paint; it controls // where the next sliver should start laying out content. (See @@ -643,10 +658,6 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper // affect the cache extent. cacheExtent: geometry.cacheExtent, ); - - headerOffset = _headerAtCoordinateEnd() - ? childExtent - headerExtent - : 0.0; } } else { // The header's item has [StickyHeaderItem.allowOverflow] false. From dc2c9f038d1e8d944a10b596a39a5d91a6246ef8 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 31 Jan 2025 15:16:00 -0800 Subject: [PATCH 061/110] sticky_header: Fix hit-testing when header overflows sliver When the sticky header overflows the sliver that provides it -- that is, when the sliver boundary is scrolled to within the area the header covers -- the existing code already got the right visual result, painting the header at its full size. But it didn't work properly for hit-testing: trying to tap the header in the portion where it's overflowing wouldn't work, and would instead go through to whatever's underneath (like the top of the next sliver). That's because the geometry it was reporting from this `performLayout` method didn't reflect the geometry it would actually paint in the `paint` method. When hit-testing, that reported geometry gets interpreted by the framework code before calling this render object's other methods. Specifically, this sliver was reporting to its parent that its paint region (described by `paintOrigin` and `paintExtent`) was the same as the child sliver's paint region. In reality, this sliver in this case will paint a larger region than that. Fix by accurately reporting this sliver's paint region (via `paintOrigin` and `paintExtent`), while adjusting `headerOffset` to be relative to the new truthful paint region rather than the old inaccurate one. This fix lets us mark a swath of test cases as no longer skipped. On the other hand, this change introduces a different bug in this overflow case: the child sliver is now painted in the wrong place if _headerAtCoordinateEnd(). (It gets lined up with the inner edge of the header, even though it's too short to reach the viewport edge from there.) That bug is latent as far as the Zulip app is concerned, so we leave it for now with a TODO. Other than that, after this fix, sticky headers overflowing into the next sliver seem to work completely correctly... as long as the viewport paints the slivers in the necessary order. We'll take care of that in an upcoming PR. --- lib/widgets/sticky_header.dart | 20 +++++--- test/widgets/sticky_header_test.dart | 73 ++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index e5bb29b4a7..5cfe923bc8 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -630,22 +630,30 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper // This sliver's paint region consists entirely of the header. final paintExtent = headerExtent; - headerOffset = _headerAtCoordinateEnd() - ? childExtent - headerExtent // TODO buggy, should be zero - : 0.0; + headerOffset = 0.0; // Its layout region (affecting where the next sliver begins layout) // is that given by the child sliver. final layoutExtent = childExtent; // The paint origin places this sliver's paint region relative to its - // layout region. - final paintOrigin = 0.0; // TODO buggy + // layout region so that they share the edge the header appears at + // (which should be the edge of the viewport). + final headerGrowthPlacement = + _widget.headerPlacement._byGrowth(constraints.growthDirection); + final paintOrigin = switch (headerGrowthPlacement) { + _HeaderGrowthPlacement.growthStart => 0.0, + _HeaderGrowthPlacement.growthEnd => layoutExtent - paintExtent, + }; + // TODO the child sliver should be painted at offset -paintOrigin + // (This bug doesn't matter so long as the header is opaque, + // because the header covers the child in that case. + // For that reason the Zulip message list isn't affected.) geometry = SliverGeometry( // TODO review interaction with other slivers scrollExtent: geometry.scrollExtent, layoutExtent: layoutExtent, - paintExtent: childExtent, // TODO buggy + paintExtent: paintExtent, paintOrigin: paintOrigin, maxPaintExtent: math.max(geometry.maxPaintExtent, paintExtent), hasVisualOverflow: geometry.hasVisualOverflow diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart index e4bfdca515..affe75e8c6 100644 --- a/test/widgets/sticky_header_test.dart +++ b/test/widgets/sticky_header_test.dart @@ -228,6 +228,48 @@ void main() { await tester.pump(); checkState(103, item: 0, header: 0); }); + + testWidgets('hit-testing for header overflowing sliver', (tester) async { + final controller = ScrollController(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: CustomScrollView( + controller: controller, + slivers: [ + SliverStickyHeaderList( + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildListDelegate( + List.generate(100, (i) => StickyHeaderItem( + allowOverflow: true, + header: _Header(i, height: 20), + child: _Item(i, height: 100))))), + SliverStickyHeaderList( + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildListDelegate( + List.generate(100, (i) => StickyHeaderItem( + allowOverflow: true, + header: _Header(100 + i, height: 20), + child: _Item(100 + i, height: 100))))), + ]))); + + const topExtent = 100 * 100; + for (double topHeight in [5, 10, 15, 20]) { + controller.jumpTo(topExtent - topHeight); + await tester.pump(); + // The top sliver occupies height [topHeight]. + // Its header overhangs by `20 - topHeight`. + + final expected = >[]; + for (int y = 1; y < 20; y++) { + await tester.tapAt(Offset(400, y.toDouble())); + expected.add((it) => it.isA<_Header>().index.equals(99)); + } + for (int y = 21; y < 40; y += 2) { + await tester.tapAt(Offset(400, y.toDouble())); + expected.add((it) => it.isA<_Item>().index.equals(100)); + } + check(_TapLogged.takeTapLog()).deepEquals(expected); + } + }); } enum _SliverConfig { @@ -255,19 +297,6 @@ Future _checkSequence( final headerPlacement = reverseHeader ^ reverse ? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart; - if (allowOverflow - && ((sliverConfig == _SliverConfig.backToBack - && (reverse ^ reverseHeader)) - || (sliverConfig == _SliverConfig.followed - && (reverse ^ reverseHeader ^ !reverseGrowth)))) { - // (The condition for this skip is pretty complicated; it's just the - // conditions where the bug gets triggered, and I haven't tried to - // work through why this exact set of cases is what's affected. - // The important thing is they all get fixed in an upcoming commit.) - markTestSkipped('bug in header overflowing sliver'); // TODO fix - return; - } - Widget buildItem(int i) { return StickyHeaderItem( allowOverflow: allowOverflow, @@ -377,7 +406,15 @@ Future _checkSequence( 100 - (first ? scrollOffset % 100 : (-scrollOffset) % 100); final double expectedHeaderInsetExtent = allowOverflow ? 20 : math.min(20, expectedItemInsetExtent); - check(insetExtent(itemFinder)).equals(expectedItemInsetExtent); + if (expectedItemInsetExtent < expectedHeaderInsetExtent) { + // TODO there's a bug here if the header isn't opaque; + // this check would exercise the bug: + // check(insetExtent(itemFinder)).equals(expectedItemInsetExtent); + // Instead, check that things will be fine if the header is opaque. + check(insetExtent(itemFinder)).isLessOrEqual(expectedHeaderInsetExtent); + } else { + check(insetExtent(itemFinder)).equals(expectedItemInsetExtent); + } check(insetExtent(find.byType(_Header))).equals(expectedHeaderInsetExtent); // Check the header gets hit when it should, and not when it shouldn't. @@ -572,6 +609,10 @@ class _Header extends StatelessWidget implements _TapLogged { } } +extension _HeaderChecks on Subject<_Header> { + Subject get index => has((x) => x.index, 'index'); +} + class _Item extends StatelessWidget implements _TapLogged { const _Item(this.index, {required this.height}); @@ -595,6 +636,10 @@ class _Item extends StatelessWidget implements _TapLogged { } } +extension _ItemChecks on Subject<_Item> { + Subject get index => has((x) => x.index, 'index'); +} + /// Sets [DeviceGestureSettings.touchSlop] for the child subtree /// to the given value, by inserting a [MediaQuery]. /// From 043ae105c0d5c419230d632d371f5416e248011d Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 31 Jan 2025 15:49:17 -0800 Subject: [PATCH 062/110] sticky_header [nfc]: Doc overflow behavior and paint-order constraints --- lib/widgets/sticky_header.dart | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 5cfe923bc8..d9d9a738dc 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -312,6 +312,27 @@ enum _HeaderGrowthPlacement { /// /// This widget takes most of its behavior from [SliverList], /// but adds sticky headers as described at [StickyHeaderListView]. +/// +/// ## Overflow across slivers +/// +/// When the list item that controls the sticky header has +/// [StickyHeaderItem.allowOverflow] true, the header will be permitted +/// to overflow not only the item but this whole sliver. +/// +/// The caller is responsible for arranging the paint order between slivers +/// so that this works correctly: a sliver that might overflow must be painted +/// after any sliver it might overflow onto. +/// For example if [headerPlacement] puts headers at the left of the viewport +/// (and any items with [StickyHeaderItem.allowOverflow] true are present), +/// then this [SliverStickyHeaderList] must paint after any slivers that appear +/// to the right of this sliver. +/// +/// At present there's no off-the-shelf way to fully control the paint order +/// between slivers. +/// See the implementation of [RenderViewport.childrenInPaintOrder] for the +/// paint order provided by [CustomScrollView]; it meets the above needs +/// for some arrangements of slivers and values of [headerPlacement], +/// but not others. class SliverStickyHeaderList extends RenderObjectWidget { SliverStickyHeaderList({ super.key, From c87d48b0a01f675f5abdcf86b61b9d91d0588ba7 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 13 Feb 2025 11:13:12 +0530 Subject: [PATCH 063/110] content: Move math block parsing to the callers of `parseBlockContent` Prepares for #1130, this commit is almost NFC with only difference that, in an error case we previously emitted a `ParagraphNode` containing an `UnimplementedInlineContentNode` (along with any adjacent nodes), now we emit a single `UnimplementedBlockContentNode` instead. --- lib/model/content.dart | 62 ++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index e228163a2e..4e5f2932f0 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -1471,21 +1471,6 @@ class _ZulipContentParser { } if (localName == 'p' && className.isEmpty) { - // Oddly, the way a math block gets encoded in Zulip HTML is inside a

. - if (element.nodes case [dom.Element(localName: 'span') && var child, ...]) { - if (child.className == 'katex-display') { - if (element.nodes case [_] - || [_, dom.Element(localName: 'br'), - dom.Text(text: "\n")]) { - // This might be too specific; we'll find out when we do #190. - // The case with the `
\n` can happen when at the end of a quote; - // it seems like a glitch in the server's Markdown processing, - // so hopefully there just aren't any further such glitches. - return parseMathBlock(child); - } - } - } - final parsed = parseBlockInline(element.nodes); return ParagraphNode(debugHtmlNode: debugHtmlNode, links: parsed.links, @@ -1599,6 +1584,30 @@ class _ZulipContentParser { for (final node in nodes) { if (node is dom.Text && (node.text == '\n')) continue; + // Oddly, the way a math block gets encoded in Zulip HTML is inside a

. + if (node case dom.Element(localName: 'p', className: '', nodes: [ + dom.Element( + localName: 'span', + className: 'katex-display') && final child, ...])) { + final BlockContentNode parsed; + if (node.nodes case [_] + || [_, dom.Element(localName: 'br'), + dom.Text(text: "\n")]) { + // This might be too specific; we'll find out when we do #190. + // The case with the `
\n` can happen when at the end of a quote; + // it seems like a glitch in the server's Markdown processing, + // so hopefully there just aren't any further such glitches. + parsed = parseMathBlock(child); + } else { + parsed = UnimplementedBlockContentNode(htmlNode: node); + } + + if (currentParagraph.isNotEmpty) consumeParagraph(); + if (imageNodes.isNotEmpty) consumeImageNodes(); + result.add(parsed); + continue; + } + if (_isPossibleInlineNode(node)) { if (imageNodes.isNotEmpty) { consumeImageNodes(); @@ -1642,6 +1651,29 @@ class _ZulipContentParser { continue; } + // Oddly, the way a math block gets encoded in Zulip HTML is inside a

. + if (node case dom.Element(localName: 'p', className: '', nodes: [ + dom.Element( + localName: 'span', + className: 'katex-display') && final child, ...])) { + final BlockContentNode parsed; + if (node.nodes case [_] + || [_, dom.Element(localName: 'br'), + dom.Text(text: "\n")]) { + // This might be too specific; we'll find out when we do #190. + // The case with the `
\n` can happen when at the end of a quote; + // it seems like a glitch in the server's Markdown processing, + // so hopefully there just aren't any further such glitches. + parsed = parseMathBlock(child); + } else { + parsed = UnimplementedBlockContentNode(htmlNode: node); + } + + if (imageNodes.isNotEmpty) consumeImageNodes(); + result.add(parsed); + continue; + } + final block = parseBlockContent(node); if (block is ImageNode) { imageNodes.add(block); From 866faf5899efb49e70b816d40e90fa277932da91 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Dec 2024 21:04:29 +0530 Subject: [PATCH 064/110] content: Handle multiple math blocks in `

` Fixes: #1130 --- lib/model/content.dart | 111 +++++++++++++++++++++-------------- test/model/content_test.dart | 76 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 43 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 4e5f2932f0..8a5204973e 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -1055,13 +1055,6 @@ class _ZulipContentParser { return inlineParser.parseBlockInline(nodes); } - BlockContentNode parseMathBlock(dom.Element element) { - final debugHtmlNode = kDebugMode ? element : null; - final texSource = _parseMath(element, block: true); - if (texSource == null) return UnimplementedBlockContentNode(htmlNode: element); - return MathBlockNode(texSource: texSource, debugHtmlNode: debugHtmlNode); - } - BlockContentNode parseListNode(dom.Element element) { ListStyle? listStyle; switch (element.localName) { @@ -1453,6 +1446,64 @@ class _ZulipContentParser { return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement); } + void parseMathBlocks(dom.NodeList nodes, List result) { + assert(nodes.isNotEmpty); + assert((() { + final first = nodes.first; + return first is dom.Element + && first.localName == 'span' + && first.className == 'katex-display'; + })()); + + final firstChild = nodes.first as dom.Element; + final texSource = _parseMath(firstChild, block: true); + if (texSource != null) { + result.add(MathBlockNode( + texSource: texSource, + debugHtmlNode: kDebugMode ? firstChild : null)); + } else { + result.add(UnimplementedBlockContentNode(htmlNode: firstChild)); + } + + // Skip further checks if there was only a single child. + if (nodes.length == 1) return; + + // The case with the `
\n` can happen when at the end of a quote; + // it seems like a glitch in the server's Markdown processing, + // so hopefully there just aren't any further such glitches. + bool hasTrailingBreakNewline = false; + if (nodes case [..., dom.Element(localName: 'br'), dom.Text(text: '\n')]) { + hasTrailingBreakNewline = true; + } + + final length = hasTrailingBreakNewline + ? nodes.length - 2 + : nodes.length; + for (int i = 1; i < length; i++) { + final child = nodes[i]; + final debugHtmlNode = kDebugMode ? child : null; + + // If there are multiple nodes in a

+ // each node is interleaved by '\n\n'. Whitespaces are ignored in HTML + // on web but each node has `display: block`, which renders each node + // on a new line. Since the emitted MathBlockNode are BlockContentNode, + // we skip these newlines here to replicate the same behavior as on web. + if (child case dom.Text(text: '\n\n')) continue; + + if (child case dom.Element(localName: 'span', className: 'katex-display')) { + final texSource = _parseMath(child, block: true); + if (texSource != null) { + result.add(MathBlockNode( + texSource: texSource, + debugHtmlNode: debugHtmlNode)); + continue; + } + } + + result.add(UnimplementedBlockContentNode(htmlNode: child)); + } + } + BlockContentNode parseBlockContent(dom.Node node) { final debugHtmlNode = kDebugMode ? node : null; if (node is! dom.Element) { @@ -1584,27 +1635,14 @@ class _ZulipContentParser { for (final node in nodes) { if (node is dom.Text && (node.text == '\n')) continue; - // Oddly, the way a math block gets encoded in Zulip HTML is inside a

. + // Oddly, the way math blocks get encoded in Zulip HTML is inside a

. + // And there can be multiple math blocks inside the paragraph node, so + // handle it explicitly here. if (node case dom.Element(localName: 'p', className: '', nodes: [ - dom.Element( - localName: 'span', - className: 'katex-display') && final child, ...])) { - final BlockContentNode parsed; - if (node.nodes case [_] - || [_, dom.Element(localName: 'br'), - dom.Text(text: "\n")]) { - // This might be too specific; we'll find out when we do #190. - // The case with the `
\n` can happen when at the end of a quote; - // it seems like a glitch in the server's Markdown processing, - // so hopefully there just aren't any further such glitches. - parsed = parseMathBlock(child); - } else { - parsed = UnimplementedBlockContentNode(htmlNode: node); - } - + dom.Element(localName: 'span', className: 'katex-display'), ...])) { if (currentParagraph.isNotEmpty) consumeParagraph(); if (imageNodes.isNotEmpty) consumeImageNodes(); - result.add(parsed); + parseMathBlocks(node.nodes, result); continue; } @@ -1651,26 +1689,13 @@ class _ZulipContentParser { continue; } - // Oddly, the way a math block gets encoded in Zulip HTML is inside a

. + // Oddly, the way math blocks get encoded in Zulip HTML is inside a

. + // And there can be multiple math blocks inside the paragraph node, so + // handle it explicitly here. if (node case dom.Element(localName: 'p', className: '', nodes: [ - dom.Element( - localName: 'span', - className: 'katex-display') && final child, ...])) { - final BlockContentNode parsed; - if (node.nodes case [_] - || [_, dom.Element(localName: 'br'), - dom.Text(text: "\n")]) { - // This might be too specific; we'll find out when we do #190. - // The case with the `
\n` can happen when at the end of a quote; - // it seems like a glitch in the server's Markdown processing, - // so hopefully there just aren't any further such glitches. - parsed = parseMathBlock(child); - } else { - parsed = UnimplementedBlockContentNode(htmlNode: node); - } - + dom.Element(localName: 'span', className: 'katex-display'), ...])) { if (imageNodes.isNotEmpty) consumeImageNodes(); - result.add(parsed); + parseMathBlocks(node.nodes, result); continue; } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 361259eb61..117c121660 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -506,6 +506,23 @@ class ContentExample { '

', [MathBlockNode(texSource: r'\lambda')]); + static const mathBlocksMultipleInParagraph = ContentExample( + 'math blocks, multiple in paragraph', + '```math\na\n\nb\n```', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2001490 + '

' + '' + 'a' + 'a' + '\n\n' + '' + 'b' + 'b' + '

', [ + MathBlockNode(texSource: 'a'), + MathBlockNode(texSource: 'b'), + ]); + static const mathBlockInQuote = ContentExample( 'math block in quote', // There's sometimes a quirky extra `
\n` at the end of the `

` that @@ -522,6 +539,62 @@ class ContentExample { '
\n

\n', [QuotationNode([MathBlockNode(texSource: r'\lambda')])]); + static const mathBlocksMultipleInQuote = ContentExample( + 'math blocks, multiple in quote', + "````quote\n```math\na\n\nb\n```\n````", + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2029236 + '
\n

' + '' + 'a' + 'a' + '' + '\n\n' + '' + 'b' + 'b' + '' + '
\n

\n
', + [QuotationNode([ + MathBlockNode(texSource: 'a'), + MathBlockNode(texSource: 'b'), + ])]); + + static const mathBlockBetweenImages = ContentExample( + 'math block between images', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Greg/near/2035891 + 'https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg\n```math\na\n```\nhttps://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg/1280px-Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg', + '
' + '' + '
' + '

' + '' + 'a' + 'a' + '' + '

\n' + '
' + '' + '
', + [ + ImageNodeList([ + ImageNode( + srcUrl: '/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067', + thumbnailUrl: null, + loading: false, + originalWidth: null, + originalHeight: null), + ]), + MathBlockNode(texSource: 'a'), + ImageNodeList([ + ImageNode( + srcUrl: '/external_content/58b0ef9a06d7bb24faec2b11df2f57f476e6f6bb/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f372f37312f5a616164706c75697a656e5f76616e5f65656e5f436c656d617469735f746578656e7369735f2532375072696e636573735f4469616e612532372e5f31382d30372d323032335f2532386163746d2e2532395f30322e6a70672f3132383070782d5a616164706c75697a656e5f76616e5f65656e5f436c656d617469735f746578656e7369735f2532375072696e636573735f4469616e612532372e5f31382d30372d323032335f2532386163746d2e2532395f30322e6a7067', + thumbnailUrl: null, + loading: false, + originalWidth: null, + originalHeight: null), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -1470,7 +1543,10 @@ void main() { testParseExample(ContentExample.codeBlockFollowedByMultipleLineBreaks); testParseExample(ContentExample.mathBlock); + testParseExample(ContentExample.mathBlocksMultipleInParagraph); testParseExample(ContentExample.mathBlockInQuote); + testParseExample(ContentExample.mathBlocksMultipleInQuote); + testParseExample(ContentExample.mathBlockBetweenImages); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); From 44df81ff4ca5c9735e1e2813b274b749eb3798b7 Mon Sep 17 00:00:00 2001 From: lakshya1goel Date: Fri, 14 Feb 2025 19:32:28 +0530 Subject: [PATCH 065/110] msglist: Move star icon 2px away from the edge of the screen Fixes: #1247 --- lib/widgets/message_list.dart | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 4d0ff00072..06ecff110f 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_color_models/flutter_color_models.dart'; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; @@ -1387,6 +1387,17 @@ class MessageWithPossibleSender extends StatelessWidget { case MessageEditState.none: } + Widget? star; + if (message.flags.contains(MessageFlag.starred)) { + final starOffset = switch (Directionality.of(context)) { + TextDirection.ltr => -2.0, + TextDirection.rtl => 2.0, + }; + star = Transform.translate( + offset: Offset(starOffset, 0), + child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); + } + return GestureDetector( behavior: HitTestBehavior.translucent, onLongPress: () => showMessageActionSheet(context: context, message: message), @@ -1418,9 +1429,7 @@ class MessageWithPossibleSender extends StatelessWidget { context, 0.05, baseFontSize: 12))), ])), SizedBox(width: 16, - child: message.flags.contains(MessageFlag.starred) - ? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star) - : null), + child: star), ]), ]))); } From 724c6b9c54d34a30df2b3039e4d343711d6252c4 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 13 Feb 2025 20:07:17 -0800 Subject: [PATCH 066/110] api [nfc]: Assert ZulipApiException.data is free of redundant keys These three keys appear in the server's JSON for error responses, but get pulled out into their own dedicated fields in ZulipApiException. (See the field's doc, and the constructor's one non-test call site.) The assertion is useful for tests, for keeping test data realistic. Fix the two test cases that had missed this nuance. --- lib/api/exception.dart | 5 ++++- test/model/actions_test.dart | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/api/exception.dart b/lib/api/exception.dart index d4bebeeb62..d495ec9ff8 100644 --- a/lib/api/exception.dart +++ b/lib/api/exception.dart @@ -81,7 +81,10 @@ class ZulipApiException extends HttpException { required this.code, required this.data, required super.message, - }) : assert(400 <= httpStatus && httpStatus <= 499); + }) : assert(400 <= httpStatus && httpStatus <= 499), + assert(!data.containsKey('result') + && !data.containsKey('code') + && !data.containsKey('msg')); @override String toString() { diff --git a/test/model/actions_test.dart b/test/model/actions_test.dart index e24bb3223c..2b38d640a6 100644 --- a/test/model/actions_test.dart +++ b/test/model/actions_test.dart @@ -105,7 +105,7 @@ void main() { final exception = ZulipApiException( httpStatus: 401, code: 'UNAUTHORIZED', - data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, + data: {}, routeName: 'removeEtcEtcToken', message: 'Invalid API key', ); @@ -174,7 +174,7 @@ void main() { ..prepare(exception: ZulipApiException( httpStatus: 401, code: 'UNAUTHORIZED', - data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"}, + data: {}, routeName: 'removeEtcEtcToken', message: 'Invalid API key', )); From fcc892603adc2c3d79deb27d3f535e5173aea6c6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 13 Feb 2025 20:20:45 -0800 Subject: [PATCH 067/110] test [nfc]: Pull out an example UNAUTHORIZED API exception, and add doc Prompted by seeing we'll need more copies of this soon: https://github.com/zulip/zulip-flutter/pull/1183#discussion_r1955516825 --- test/example_data.dart | 19 +++++++++++++++++++ test/model/actions_test.dart | 18 +++--------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index 6b84bf185c..6cab496157 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; +import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -18,10 +19,28 @@ void _checkPositive(int? value, String description) { assert(value == null || value > 0, '$description should be positive'); } +//////////////////////////////////////////////////////////////// +// Error objects. +// + Object nullCheckError() { try { null!; } catch (e) { return e; } // ignore: null_check_always_fails } +/// The error the server gives when the client's credentials +/// (API key together with email and realm URL) are no longer valid. +/// +/// This isn't really documented, but comes from experiment and from +/// reading the server implementation. See: +/// https://github.com/zulip/zulip-flutter/pull/1183#discussion_r1945865983 +/// https://chat.zulip.org/#narrow/channel/378-api-design/topic/general.20handling.20HTTP.20status.20code.20401/near/2090024 +ZulipApiException apiExceptionUnauthorized({String routeName = 'someRoute'}) { + return ZulipApiException( + routeName: routeName, + httpStatus: 401, code: 'UNAUTHORIZED', + data: {}, message: 'Invalid API key'); +} + //////////////////////////////////////////////////////////////// // Realm-wide (or server-wide) metadata. // diff --git a/test/model/actions_test.dart b/test/model/actions_test.dart index 2b38d640a6..46cc2c6bdd 100644 --- a/test/model/actions_test.dart +++ b/test/model/actions_test.dart @@ -2,7 +2,6 @@ import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:zulip/api/exception.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/receive.dart'; @@ -102,13 +101,7 @@ void main() { check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id); const unregisterDelay = Duration(seconds: 5); assert(unregisterDelay > TestGlobalStore.removeAccountDuration); - final exception = ZulipApiException( - httpStatus: 401, - code: 'UNAUTHORIZED', - data: {}, - routeName: 'removeEtcEtcToken', - message: 'Invalid API key', - ); + final exception = eg.apiExceptionUnauthorized(routeName: 'removeEtcEtcToken'); final newConnection = separateConnection() ..prepare(delay: unregisterDelay, exception: exception); @@ -170,14 +163,9 @@ void main() { test('connection closed if request errors', () => awaitFakeAsync((async) async { await prepare(ackedPushToken: '123'); + final exception = eg.apiExceptionUnauthorized(routeName: 'removeEtcEtcToken'); final newConnection = separateConnection() - ..prepare(exception: ZulipApiException( - httpStatus: 401, - code: 'UNAUTHORIZED', - data: {}, - routeName: 'removeEtcEtcToken', - message: 'Invalid API key', - )); + ..prepare(exception: exception); final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); async.elapse(Duration.zero); await future; From 27860551db2f7fd09f5e9fa485c0d838e9955a18 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 15 Feb 2025 16:11:41 -0800 Subject: [PATCH 068/110] fake_api [nfc]: Clarify prepared exception is at HTTP layer And leave a couple of TODO comments in the one test file that was accidentally preparing exceptions here that won't turn out in the intended way. The next commit will provide a clean way for these tests to do what they intend instead. --- test/api/core_test.dart | 2 +- test/api/fake_api.dart | 15 +++++++++------ test/api/fake_api_test.dart | 11 ++++++++++- test/model/actions_test.dart | 4 ++-- test/model/store_test.dart | 8 ++++---- test/widgets/action_sheet_test.dart | 6 +++--- test/widgets/actions_test.dart | 2 +- test/widgets/message_list_test.dart | 2 +- 8 files changed, 31 insertions(+), 19 deletions(-) diff --git a/test/api/core_test.dart b/test/api/core_test.dart index b45cdeaebf..297dcfc441 100644 --- a/test/api/core_test.dart +++ b/test/api/core_test.dart @@ -504,7 +504,7 @@ Future tryRequest({ fromJson ??= (((Map x) => x) as T Function(Map)); return FakeApiConnection.with_((connection) { connection.prepare( - exception: exception, httpStatus: httpStatus, json: json, body: body); + httpException: exception, httpStatus: httpStatus, json: json, body: body); return connection.get(kExampleRouteName, fromJson!, 'example/route', {}); }); } diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index 5e91a55cb2..d1e27510d8 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -209,20 +209,23 @@ class FakeApiConnection extends ApiConnection { List takeRequests() => client.takeRequests(); - /// Prepare the response for the next request. + /// Prepare the HTTP response for the next request. /// - /// If `exception` is null, the next request will produce an [http.Response] + /// If `httpException` is null, the next request will produce an [http.Response] /// with the given `httpStatus`, defaulting to 200. The body of the response /// will be `body` if non-null, or `jsonEncode(json)` if `json` is non-null, /// or else ''. The `body` and `json` parameters must not both be non-null. /// - /// If `exception` is non-null, then `httpStatus`, `body`, and `json` must - /// all be null, and the next request will throw the given exception. + /// If `httpException` is non-null, then + /// `httpStatus`, `body`, and `json` must all be null, and the next request + /// will throw the given exception within the HTTP client layer, + /// causing the API request to throw a [NetworkException] + /// wrapping the given exception. /// /// In either case, the next request will complete a duration of `delay` /// after being started. void prepare({ - Object? exception, + Object? httpException, int? httpStatus, Map? json, String? body, @@ -230,7 +233,7 @@ class FakeApiConnection extends ApiConnection { }) { assert(isOpen); client.prepare( - exception: exception, + exception: httpException, httpStatus: httpStatus, json: json, body: body, delay: delay, ); diff --git a/test/api/fake_api_test.dart b/test/api/fake_api_test.dart index 25b4bcff2e..c909488e3e 100644 --- a/test/api/fake_api_test.dart +++ b/test/api/fake_api_test.dart @@ -25,6 +25,15 @@ void main() { ..asString.contains('FakeApiConnection.prepare')); }); + test('prepare HTTP exception -> get NetworkException', () async { + final connection = FakeApiConnection(); + final exception = Exception('oops'); + connection.prepare(httpException: exception); + await check(connection.get('aRoute', (json) => json, '/', null)) + .throws((it) => it.isA() + ..cause.identicalTo(exception)); + }); + test('delay success', () => awaitFakeAsync((async) async { final connection = FakeApiConnection(); connection.prepare(delay: const Duration(seconds: 2), @@ -44,7 +53,7 @@ void main() { test('delay exception', () => awaitFakeAsync((async) async { final connection = FakeApiConnection(); connection.prepare(delay: const Duration(seconds: 2), - exception: Exception("oops")); + httpException: Exception("oops")); Object? error; unawaited(connection.get('aRoute', (json) => null, '/', null) diff --git a/test/model/actions_test.dart b/test/model/actions_test.dart index 46cc2c6bdd..45670814de 100644 --- a/test/model/actions_test.dart +++ b/test/model/actions_test.dart @@ -103,7 +103,7 @@ void main() { assert(unregisterDelay > TestGlobalStore.removeAccountDuration); final exception = eg.apiExceptionUnauthorized(routeName: 'removeEtcEtcToken'); final newConnection = separateConnection() - ..prepare(delay: unregisterDelay, exception: exception); + ..prepare(delay: unregisterDelay, httpException: exception); // TODO this isn't an HTTP exception final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); // Unregister-token request and account removal dispatched together @@ -165,7 +165,7 @@ void main() { final exception = eg.apiExceptionUnauthorized(routeName: 'removeEtcEtcToken'); final newConnection = separateConnection() - ..prepare(exception: exception); + ..prepare(httpException: exception); // TODO this isn't an HTTP exception final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); async.elapse(Duration.zero); await future; diff --git a/test/model/store_test.dart b/test/model/store_test.dart index bc393d6d6f..ad80a6263d 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -473,7 +473,7 @@ void main() { // Try to load, inducing an error in the request. globalStore.useCachedApiConnections = true; - connection.prepare(exception: Exception('failed')); + connection.prepare(httpException: Exception('failed')); final future = UpdateMachine.load(globalStore, eg.selfAccount.id); bool complete = false; unawaited(future.whenComplete(() => complete = true)); @@ -541,7 +541,7 @@ void main() { check(store.debugServerEmojiData).isNull(); // Try to fetch, inducing an error in the request. - connection.prepare(exception: Exception('failed')); + connection.prepare(httpException: Exception('failed')); final future = updateMachine.fetchEmojiData(emojiDataUrl); bool complete = false; unawaited(future.whenComplete(() => complete = true)); @@ -712,11 +712,11 @@ void main() { } void prepareNetworkExceptionSocketException() { - connection.prepare(exception: const SocketException('failed')); + connection.prepare(httpException: const SocketException('failed')); } void prepareNetworkException() { - connection.prepare(exception: Exception("failed")); + connection.prepare(httpException: Exception("failed")); } void prepareServer5xxException() { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 7da94cfd36..7dbaaeaacf 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -545,7 +545,7 @@ void main() { await prepare(topic: 'zulip'); await showFromRecipientHeader(tester, message: message); connection.takeRequests(); - connection.prepare(exception: http.ClientException('Oops')); + connection.prepare(httpException: http.ClientException('Oops')); await tester.tap(findButtonForLabel('Mark as resolved')); await tester.pumpAndSettle(); checkRequest(message.id, '✔ zulip'); @@ -559,7 +559,7 @@ void main() { await prepare(topic: '✔ zulip'); await showFromRecipientHeader(tester, message: message); connection.takeRequests(); - connection.prepare(exception: http.ClientException('Oops')); + connection.prepare(httpException: http.ClientException('Oops')); await tester.tap(findButtonForLabel('Mark as unresolved')); await tester.pumpAndSettle(); checkRequest(message.id, 'zulip'); @@ -1016,7 +1016,7 @@ void main() { final message = eg.streamMessage(flags: [MessageFlag.read]); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - connection.prepare(exception: http.ClientException('Oops')); + connection.prepare(httpException: http.ClientException('Oops')); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 290d6f3e53..9148cf4fbf 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -329,7 +329,7 @@ void main() { testWidgets('catch-all api errors', (tester) async { await prepare(tester); - connection.prepare(exception: http.ClientException('Oops')); + connection.prepare(httpException: http.ClientException('Oops')); final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); await tester.pump(Duration.zero); checkErrorDialog(tester, diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 3f79f8cae6..6deb686a47 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -694,7 +694,7 @@ void main() { narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); check(isMarkAsReadButtonVisible(tester)).isTrue(); - connection.prepare(exception: http.ClientException('Oops')); + connection.prepare(httpException: http.ClientException('Oops')); await tester.tap(find.byType(MarkAsReadWidget)); await tester.pumpAndSettle(); checkErrorDialog(tester, From b7194f08c223abbfa2d4563346c3a862ed31c3f5 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 15 Feb 2025 16:22:16 -0800 Subject: [PATCH 069/110] fake_api: Add apiException parameter; switch to it where needed --- test/api/fake_api.dart | 27 ++++++++++++++++++++++++--- test/api/fake_api_test.dart | 16 ++++++++++++++++ test/model/actions_test.dart | 4 ++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index d1e27510d8..c0f20c8fa6 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:zulip/api/core.dart'; +import 'package:zulip/api/exception.dart'; import 'package:zulip/model/store.dart'; import '../example_data.dart' as eg; @@ -211,27 +212,47 @@ class FakeApiConnection extends ApiConnection { /// Prepare the HTTP response for the next request. /// - /// If `httpException` is null, the next request will produce an [http.Response] + /// If `httpException` and `apiException` are both null, then + /// the next request will produce an [http.Response] /// with the given `httpStatus`, defaulting to 200. The body of the response /// will be `body` if non-null, or `jsonEncode(json)` if `json` is non-null, /// or else ''. The `body` and `json` parameters must not both be non-null. /// - /// If `httpException` is non-null, then + /// If `httpException` is non-null, then `apiException`, /// `httpStatus`, `body`, and `json` must all be null, and the next request /// will throw the given exception within the HTTP client layer, /// causing the API request to throw a [NetworkException] /// wrapping the given exception. /// - /// In either case, the next request will complete a duration of `delay` + /// If `apiException` is non-null, then `httpException`, + /// `httpStatus`, `body`, and `json` must all be null, and the next request + /// will throw an exception equivalent to the given exception + /// (except [ApiRequestException.routeName], which is ignored). + /// + /// In each case, the next request will complete a duration of `delay` /// after being started. void prepare({ Object? httpException, + ZulipApiException? apiException, int? httpStatus, Map? json, String? body, Duration delay = Duration.zero, }) { assert(isOpen); + + if (apiException != null) { + assert(httpException == null + && httpStatus == null && json == null && body == null); + httpStatus = apiException.httpStatus; + json = { + 'result': 'error', + 'code': apiException.code, + 'msg': apiException.message, + ...apiException.data, + }; + } + client.prepare( exception: httpException, httpStatus: httpStatus, json: json, body: body, diff --git a/test/api/fake_api_test.dart b/test/api/fake_api_test.dart index c909488e3e..982c002e8b 100644 --- a/test/api/fake_api_test.dart +++ b/test/api/fake_api_test.dart @@ -34,6 +34,22 @@ void main() { ..cause.identicalTo(exception)); }); + test('prepare API exception', () async { + final connection = FakeApiConnection(); + final exception = ZulipApiException(routeName: 'someRoute', + httpStatus: 456, code: 'SOME_ERROR', + data: {'foo': ['bar']}, message: 'Something failed'); + connection.prepare(apiException: exception); + await check(connection.get('aRoute', (json) => json, '/', null)) + .throws((it) => it.isA() + ..routeName.equals('aRoute') // actual route, not the prepared one + ..routeName.not((it) => it.equals(exception.routeName)) + ..httpStatus.equals(exception.httpStatus) + ..code.equals(exception.code) + ..data.deepEquals(exception.data) + ..message.equals(exception.message)); + }); + test('delay success', () => awaitFakeAsync((async) async { final connection = FakeApiConnection(); connection.prepare(delay: const Duration(seconds: 2), diff --git a/test/model/actions_test.dart b/test/model/actions_test.dart index 45670814de..ab97625c98 100644 --- a/test/model/actions_test.dart +++ b/test/model/actions_test.dart @@ -103,7 +103,7 @@ void main() { assert(unregisterDelay > TestGlobalStore.removeAccountDuration); final exception = eg.apiExceptionUnauthorized(routeName: 'removeEtcEtcToken'); final newConnection = separateConnection() - ..prepare(delay: unregisterDelay, httpException: exception); // TODO this isn't an HTTP exception + ..prepare(delay: unregisterDelay, apiException: exception); final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); // Unregister-token request and account removal dispatched together @@ -165,7 +165,7 @@ void main() { final exception = eg.apiExceptionUnauthorized(routeName: 'removeEtcEtcToken'); final newConnection = separateConnection() - ..prepare(httpException: exception); // TODO this isn't an HTTP exception + ..prepare(apiException: exception); final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id); async.elapse(Duration.zero); await future; From 1b13a8e08043f6841ff6f1aacddccc3f872e8ba2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 15 Feb 2025 16:51:06 -0800 Subject: [PATCH 070/110] fake_api [nfc]: Check for confusion in httpException use --- test/api/fake_api.dart | 18 ++++++++++++++++++ test/api/fake_api_test.dart | 10 ++++++++++ test/stdlib_checks.dart | 4 ++++ 3 files changed, 32 insertions(+) diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index c0f20c8fa6..6953515c6b 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -241,6 +241,24 @@ class FakeApiConnection extends ApiConnection { }) { assert(isOpen); + // The doc on [http.BaseClient.send] goes further than the following + // condition, suggesting that any exception thrown there should be an + // [http.ClientException]. But from the upstream implementation, in the + // actual live app, we already get TlsException and SocketException, + // without them getting wrapped in http.ClientException as that specifies. + // So naturally our tests need to simulate those too. + if (httpException is ApiRequestException) { + throw FlutterError.fromParts([ + ErrorSummary('FakeApiConnection.prepare was passed an ApiRequestException.'), + ErrorDescription( + 'The `httpException` parameter to FakeApiConnection.prepare describes ' + 'an exception for the underlying HTTP request to throw. ' + 'In the actual app, that will never be a Zulip-specific exception ' + 'like an ApiRequestException.'), + ErrorHint('Try using the `apiException` parameter instead.') + ]); + } + if (apiException != null) { assert(httpException == null && httpStatus == null && json == null && body == null); diff --git a/test/api/fake_api_test.dart b/test/api/fake_api_test.dart index 982c002e8b..53dae4e870 100644 --- a/test/api/fake_api_test.dart +++ b/test/api/fake_api_test.dart @@ -5,6 +5,7 @@ import 'package:test/scaffolding.dart'; import 'package:zulip/api/exception.dart'; import '../fake_async.dart'; +import '../stdlib_checks.dart'; import 'exception_checks.dart'; import 'fake_api.dart'; @@ -34,6 +35,15 @@ void main() { ..cause.identicalTo(exception)); }); + test('error message on prepare API exception as "HTTP exception"', () async { + final connection = FakeApiConnection(); + final exception = ZulipApiException(routeName: 'someRoute', + httpStatus: 456, code: 'SOME_ERROR', + data: {'foo': ['bar']}, message: 'Something failed'); + check(() => connection.prepare(httpException: exception)) + .throws().asString.contains('apiException'); + }); + test('prepare API exception', () async { final connection = FakeApiConnection(); final exception = ZulipApiException(routeName: 'someRoute', diff --git a/test/stdlib_checks.dart b/test/stdlib_checks.dart index 2d55ba0c37..792588ee75 100644 --- a/test/stdlib_checks.dart +++ b/test/stdlib_checks.dart @@ -25,6 +25,10 @@ extension NullableMapChecks on Subject?> { } } +extension ErrorChecks on Subject { + Subject get asString => has((x) => x.toString(), 'toString'); // TODO(checks): what's a good convention for this? +} + /// Convert [object] to a pure JSON-like value. /// /// The result is similar to `jsonDecode(jsonEncode(object))`, but without From f43d2e03b3f4af88d4ecc3bcd14eafbc5a0ece62 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 15 Feb 2025 16:54:14 -0800 Subject: [PATCH 071/110] test [nfc]: Introduce eg.apiBadRequest This changes the message string in a few of the call sites, where the string was already an arbitrary one rather than a realistic specific string. That's still NFC because the tests weren't depending on the specific string. --- test/api/route/messages_test.dart | 8 ++----- test/example_data.dart | 12 +++++++++++ test/model/message_list_test.dart | 9 +++----- test/widgets/action_sheet_test.dart | 31 +++++++-------------------- test/widgets/compose_box_test.dart | 9 ++------ test/widgets/emoji_reaction_test.dart | 7 +----- test/widgets/message_list_test.dart | 9 ++------ 7 files changed, 30 insertions(+), 55 deletions(-) diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 4da4334bae..0d969f0531 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -65,12 +65,8 @@ void main() { test('modern; message not found', () { return FakeApiConnection.with_((connection) async { final message = eg.streamMessage(); - final fakeResponseJson = { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }; - connection.prepare(httpStatus: 400, json: fakeResponseJson); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); final result = await checkGetMessageCompat(connection, expectLegacy: false, messageId: message.id, diff --git a/test/example_data.dart b/test/example_data.dart index 6cab496157..116a9a1bd8 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -27,6 +27,18 @@ Object nullCheckError() { try { null!; } catch (e) { return e; } // ignore: null_check_always_fails } +/// A Zulip API error with the generic "BAD_REQUEST" error code. +/// +/// The server returns this error code for a wide range of error conditions; +/// it's the default within the server code when no more-specific code is chosen. +ZulipApiException apiBadRequest({ + String routeName = 'someRoute', String message = 'Something failed'}) { + return ZulipApiException( + routeName: routeName, + httpStatus: 400, code: 'BAD_REQUEST', + data: {}, message: message); +} + /// The error the server gives when the client's credentials /// (API key together with email and realm URL) are no longer valid. /// diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 6a1d103c84..2fa97fd5e3 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -247,8 +247,7 @@ void main() { await prepareMessages(foundOldest: false, messages: initialMessages); check(connection.takeRequests()).single; - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}); + connection.prepare(apiException: eg.apiBadRequest()); check(async.pendingTimers).isEmpty(); await check(model.fetchOlder()).throws(); checkNotified(count: 2); @@ -1061,8 +1060,7 @@ void main() { addTearDown(() => BackoffMachine.debugDuration = null); await prepareNarrow(narrow, initialMessages); - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}); + connection.prepare(apiException: eg.apiBadRequest()); BackoffMachine.debugDuration = const Duration(seconds: 1); await check(model.fetchOlder()).throws(); final backoffTimerA = async.pendingTimers.single; @@ -1094,8 +1092,7 @@ void main() { check(model).fetchOlderCoolingDown.isFalse(); check(backoffTimerA.isActive).isTrue(); - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}); + connection.prepare(apiException: eg.apiBadRequest()); BackoffMachine.debugDuration = const Duration(seconds: 2); await check(model.fetchOlder()).throws(); final backoffTimerB = async.pendingTimers.last; diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 7dbaaeaacf..5a4c22c604 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -100,12 +100,7 @@ void main() { } void prepareRawContentResponseError() { - final fakeResponseJson = { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }; - connection.prepare(httpStatus: 400, json: fakeResponseJson); + connection.prepare(apiException: eg.apiBadRequest(message: 'Invalid message(s)')); } group('topic action sheet', () { @@ -377,8 +372,7 @@ void main() { isChannelMuted: false, visibilityPolicy: UserTopicVisibilityPolicy.followed); - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': ''}); + connection.prepare(apiException: eg.apiBadRequest()); await tester.tap(unfollow); await tester.pumpAndSettle(); @@ -629,11 +623,8 @@ void main() { final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); await tapButton(tester); await tester.pump(Duration.zero); // error arrives; error dialog shows @@ -698,11 +689,8 @@ void main() { await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); await tapButton(tester); await tester.pump(Duration.zero); // error arrives; error dialog shows @@ -716,11 +704,8 @@ void main() { await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); await tapButton(tester, starred: true); await tester.pump(Duration.zero); // error arrives; error dialog shows diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index f1eb9bb3ba..896dec7efe 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -553,13 +553,8 @@ void main() { testWidgets('ZulipApiException', (tester) async { await setupAndTapSend(tester, prepareResponse: (message) { - connection.prepare( - httpStatus: 400, - json: { - 'result': 'error', - 'code': 'BAD_REQUEST', - 'msg': 'You do not have permission to initiate direct message conversations.', - }); + connection.prepare(apiException: eg.apiBadRequest( + message: 'You do not have permission to initiate direct message conversations.')); }); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await tester.tap(find.byWidget(checkErrorDialog(tester, diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 89333ee1af..7ed54590ef 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -452,12 +452,7 @@ void main() { connection.prepare( delay: const Duration(seconds: 2), - httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); - + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); await tester.tap(find.descendant( of: find.byType(BottomSheet), matching: find.text('\u{1f4a4}'))); // 'zzz' emoji diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 6deb686a47..58782a30ec 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -595,15 +595,10 @@ void main() { await setupMessageListPage(tester, narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); check(isMarkAsReadButtonVisible(tester)).isTrue(); - - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); - checkAppearsLoading(tester, false); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); await tester.tap(find.byType(MarkAsReadWidget)); await tester.pump(); checkAppearsLoading(tester, true); From 48d67f113cea3fc3084218f9398d898029aacb40 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 2 Jan 2025 02:10:17 -0500 Subject: [PATCH 072/110] l10n [nfc]: Use a generalized name for errorCouldNotConnectTitle Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 4 ++-- lib/generated/l10n/zulip_localizations.dart | 2 +- lib/generated/l10n/zulip_localizations_ar.dart | 2 +- lib/generated/l10n/zulip_localizations_en.dart | 2 +- lib/generated/l10n/zulip_localizations_ja.dart | 2 +- lib/generated/l10n/zulip_localizations_nb.dart | 2 +- lib/generated/l10n/zulip_localizations_pl.dart | 2 +- lib/generated/l10n/zulip_localizations_ru.dart | 2 +- lib/generated/l10n/zulip_localizations_sk.dart | 2 +- lib/widgets/login.dart | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 319c9e9dbc..f83ab27199 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -214,8 +214,8 @@ "url": {"type": "String", "example": "http://example.com/"} } }, - "errorLoginCouldNotConnectTitle": "Could not connect", - "@errorLoginCouldNotConnectTitle": { + "errorCouldNotConnectTitle": "Could not connect", + "@errorCouldNotConnectTitle": { "description": "Error title when the app could not connect to the server." }, "errorMessageDoesNotSeemToExist": "That message does not seem to exist.", diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b3a8752ba1..3946112973 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -397,7 +397,7 @@ abstract class ZulipLocalizations { /// /// In en, this message translates to: /// **'Could not connect'** - String get errorLoginCouldNotConnectTitle; + String get errorCouldNotConnectTitle; /// Error message when loading a message that does not exist. /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 967c7fb33c..07983fe188 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -179,7 +179,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorLoginCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 83d2af10b6..b1f5cdaf8f 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -179,7 +179,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorLoginCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 034bcd17d0..258e51dce0 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -179,7 +179,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorLoginCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 6416f59b08..95650fe317 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -179,7 +179,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorLoginCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index cab571c163..c8151c8daf 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -179,7 +179,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorLoginCouldNotConnectTitle => 'Nie można połączyć'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override String get errorMessageDoesNotSeemToExist => 'Taka wiadomość raczej nie istnieje.'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index babbc976fd..876f2ad89a 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -179,7 +179,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorLoginCouldNotConnectTitle => 'Не удалось подключиться'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override String get errorMessageDoesNotSeemToExist => 'Это сообщение, похоже, отсутствует.'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index ac3b93b024..d59c8456c0 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -179,7 +179,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { } @override - String get errorLoginCouldNotConnectTitle => 'Nepodarilo sa pripojiť'; + String get errorCouldNotConnectTitle => 'Could not connect'; @override String get errorMessageDoesNotSeemToExist => 'Správa zrejme neexistuje.'; diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index 195bf75e62..504289adc1 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -190,7 +190,7 @@ class _AddAccountPageState extends State { // TODO(#105) give more helpful feedback; see `fetchServerSettings` // in zulip-mobile's src/message/fetchActions.js. showErrorDialog(context: context, - title: zulipLocalizations.errorLoginCouldNotConnectTitle, + title: zulipLocalizations.errorCouldNotConnectTitle, message: zulipLocalizations.errorLoginCouldNotConnect(url.toString())); return; } From 710c4a11d72347ee37e514f8e0a4da4fd9550be5 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 2 Jan 2025 02:20:29 -0500 Subject: [PATCH 073/110] log [nfc]: Rename ReportErrorCallback to ReportErrorCancellablyCallback This highlights the API choice that the callback signature allows the caller to clear/cancel the reported errors, by passing `null` for the `message` parameter, drawing distinction from a later added variant that does not allow this. Signed-off-by: Zixuan James Li --- lib/log.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/log.dart b/lib/log.dart index e3261f8cba..3f3c683104 100644 --- a/lib/log.dart +++ b/lib/log.dart @@ -31,7 +31,10 @@ bool debugLog(String message) { return true; } -typedef ReportErrorCallback = void Function(String? message, {String? details}); +// This should only be used for error reporting functions that allow the error +// to be cancelled programmatically. The implementation is expected to handle +// `null` for the `message` parameter and promptly dismiss the reported errors. +typedef ReportErrorCancellablyCallback = void Function(String? message, {String? details}); /// Show the user an error message, without requiring them to interact with it. /// @@ -48,7 +51,7 @@ typedef ReportErrorCallback = void Function(String? message, {String? details}); // This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart` // from importing widget code, because the file is a dependency for the rest of // the app. -ReportErrorCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly; +ReportErrorCancellablyCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly; void defaultReportErrorToUserBriefly(String? message, {String? details}) { // Error dismissing is a no-op to the default handler. From 293f2138b98d73cdec96146430bf70b181a2fac0 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 13 Feb 2025 15:24:42 -0500 Subject: [PATCH 074/110] app test [nfc]: Move error reporting tests to a separate group to set them apart from 'scaffoldMessenger' tests. Signed-off-by: Zixuan James Li --- test/widgets/app_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index af386bfdf1..9c6c1a8ded 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -245,7 +245,9 @@ void main() { check(ZulipApp.scaffoldMessenger).isNotNull(); check(ZulipApp.ready).value.isTrue(); }); + }); + group('error reporting', () { Finder findSnackBarByText(String text) => find.descendant( of: find.byType(SnackBar), matching: find.text(text)); @@ -307,7 +309,7 @@ void main() { check(findSnackBarByText(message).evaluate()).single; } - testWidgets('reportErrorToUser dismissing SnackBar', (tester) async { + testWidgets('reportErrorToUserBriefly dismissing SnackBar', (tester) async { const message = 'test error message'; const details = 'error details'; await prepareSnackBarWithDetails(tester, message, details); From 4b3ae12d5d9020062607a7b276fe79af1d16d451 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 13 Feb 2025 22:26:21 -0500 Subject: [PATCH 075/110] log [nfc]: Pull out _reportErrorToConsole Signed-off-by: Zixuan James Li --- lib/log.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/log.dart b/lib/log.dart index 3f3c683104..ef983e1bb1 100644 --- a/lib/log.dart +++ b/lib/log.dart @@ -54,7 +54,11 @@ typedef ReportErrorCancellablyCallback = void Function(String? message, {String? ReportErrorCancellablyCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly; void defaultReportErrorToUserBriefly(String? message, {String? details}) { - // Error dismissing is a no-op to the default handler. + _reportErrorToConsole(message, details); +} + +void _reportErrorToConsole(String? message, String? details) { + // Error dismissing is a no-op for the console. if (message == null) return; // If this callback is still in place, then the app's widget tree // hasn't mounted yet even as far as the [Navigator]. From 639c4081da7276252f61b49bbbd06726ed94aa29 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 2 Jan 2025 02:12:20 -0500 Subject: [PATCH 076/110] log: Add reportErrorModally Signed-off-by: Zixuan James Li --- lib/log.dart | 17 +++++++++++++++++ lib/widgets/app.dart | 12 ++++++++++++ test/widgets/app_test.dart | 19 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/lib/log.dart b/lib/log.dart index ef983e1bb1..60bcfb637b 100644 --- a/lib/log.dart +++ b/lib/log.dart @@ -36,6 +36,8 @@ bool debugLog(String message) { // `null` for the `message` parameter and promptly dismiss the reported errors. typedef ReportErrorCancellablyCallback = void Function(String? message, {String? details}); +typedef ReportErrorCallback = void Function(String title, {String? message}); + /// Show the user an error message, without requiring them to interact with it. /// /// Typically this shows a [SnackBar] containing the message. @@ -53,10 +55,25 @@ typedef ReportErrorCancellablyCallback = void Function(String? message, {String? // the app. ReportErrorCancellablyCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly; +/// Show the user a dismissable error message in a modal popup. +/// +/// Typically this shows an [AlertDialog] with `title` as the title, `message` +/// as the body. If called before the app's widget tree is ready +/// (see [ZulipApp.ready]), then we give up on showing the message to the user, +/// and just log the message to the console. +// This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart` +// from importing widget code, because the file is a dependency for the rest of +// the app. +ReportErrorCallback reportErrorToUserModally = defaultReportErrorToUserModally; + void defaultReportErrorToUserBriefly(String? message, {String? details}) { _reportErrorToConsole(message, details); } +void defaultReportErrorToUserModally(String title, {String? message}) { + _reportErrorToConsole(title, message); +} + void _reportErrorToConsole(String? message, String? details) { // Error dismissing is a no-op for the console. if (message == null) return; diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 2ad35e3e53..2c3d3cb919 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -85,6 +85,7 @@ class ZulipApp extends StatefulWidget { static void debugReset() { _snackBarCount = 0; reportErrorToUserBriefly = defaultReportErrorToUserBriefly; + reportErrorToUserModally = defaultReportErrorToUserModally; _ready.dispose(); _ready = ValueNotifier(false); } @@ -128,10 +129,21 @@ class ZulipApp extends StatefulWidget { newSnackBar.closed.whenComplete(() => _snackBarCount--); } + /// The callback we normally use as [reportErrorToUserModally]. + static void _reportErrorToUserModally(String title, {String? message}) { + assert(_ready.value); + + showErrorDialog( + context: navigatorKey.currentContext!, + title: title, + message: message); + } + void _declareReady() { assert(navigatorKey.currentContext != null); _ready.value = true; reportErrorToUserBriefly = _reportErrorToUserBriefly; + reportErrorToUserModally = _reportErrorToUserModally; } @override diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index 9c6c1a8ded..fd198a9d7c 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -363,5 +363,24 @@ void main() { await tester.pumpAndSettle(); check(findSnackBarByText('unrelated').evaluate()).single; }); + + testWidgets('reportErrorToUserModally', (tester) async { + addTearDown(testBinding.reset); + await tester.pumpWidget(const ZulipApp()); + const title = 'test title'; + const message = 'test message'; + + // Prior to app startup, reportErrorToUserModally only logs. + reportErrorToUserModally(title, message: message); + check(ZulipApp.ready).value.isFalse(); + await tester.pump(); + checkNoErrorDialog(tester); + + check(ZulipApp.ready).value.isTrue(); + // After app startup, reportErrorToUserModally displays an [AlertDialog]. + reportErrorToUserModally(title, message: message); + await tester.pump(); + checkErrorDialog(tester, expectedTitle: title, expectedMessage: message); + }); }); } From 87cfc06ca1431f6040ce5d04d4eb6c5c8ad60355 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 7 Jan 2025 17:20:48 +0800 Subject: [PATCH 077/110] action test [nfc]: Remove irrelevant issue reference Coming up with a realistic test case doesn't actually require invalidating API key. Because the goal is to use routes that exist in the app (`InboxPageBody` has become a part of `HomePage` and doesn't exist on its own), we can set up HomePage and MessageListPage instead. Signed-off-by: Zixuan James Li --- test/widgets/store_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index 7e70f01a19..dc67647eb7 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -203,7 +203,7 @@ void main() { final pushedRoutes = >[]; testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); - // TODO(#737): switch to a realistic setup: + // TODO: switch to a realistic setup: // https://github.com/zulip/zulip-flutter/pull/1076#discussion_r1874124363 final account1Route = MaterialAccountWidgetRoute( accountId: account1.id, page: const InboxPageBody()); From ade1f434bb9cc9a6b635e91671fa1443ae8c429b Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 7 Feb 2025 18:31:21 -0500 Subject: [PATCH 078/110] store [nfc]: Add TODO for handling auth errors when polling Signed-off-by: Zixuan James Li --- lib/model/store.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/model/store.dart b/lib/model/store.dart index 7603c7f452..6078b2d511 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -1177,6 +1177,7 @@ class UpdateMachine { store.isLoading = true; bool isUnexpected; + // TODO(#1054): handle auth failure switch (error) { case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'): assert(debugLog('Lost event queue for $store. Replacing…')); From 6cd9e0769cec215cb3f08b7184a56d06554d9ebb Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 7 Feb 2025 18:33:15 -0500 Subject: [PATCH 079/110] store [nfc]: Mention issue number for register-queue feedback Signed-off-by: Zixuan James Li --- lib/model/store.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 6078b2d511..d7c4632959 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -918,7 +918,7 @@ class UpdateMachine { // at 1 kiB (at least on Android), and stack can be longer than that. assert(debugLog('Stack:\n$s')); assert(debugLog('Backing off, then will retry…')); - // TODO tell user if initial-fetch errors persist, or look non-transient + // TODO(#890): tell user if initial-fetch errors persist, or look non-transient await (backoffMachine ??= BackoffMachine()).wait(); assert(debugLog('… Backoff wait complete, retrying initial fetch.')); } From 608a028532065981f4e419a964cadbb25c426db1 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 7 Feb 2025 18:44:32 -0500 Subject: [PATCH 080/110] app: Maintain that the navigator stack is never empty Signed-off-by: Zixuan James Li --- lib/widgets/app.dart | 33 ++++++++++++++++++++++++++++++++- test/widgets/app_test.dart | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 2c3d3cb919..73a4e5f232 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -223,7 +223,11 @@ class _ZulipAppState extends State with WidgetsBindingObserver { theme: themeData, navigatorKey: ZulipApp.navigatorKey, - navigatorObservers: widget.navigatorObservers ?? const [], + navigatorObservers: [ + if (widget.navigatorObservers != null) + ...widget.navigatorObservers!, + _PreventEmptyStack(), + ], builder: (BuildContext context, Widget? child) { if (!ZulipApp.ready.value) { SchedulerBinding.instance.addPostFrameCallback( @@ -246,6 +250,33 @@ class _ZulipAppState extends State with WidgetsBindingObserver { } } +/// Pushes a route whenever the observed navigator stack becomes empty. +class _PreventEmptyStack extends NavigatorObserver { + void _pushRouteIfEmptyStack() async { + final navigator = await ZulipApp.navigator; + bool isEmptyStack = true; + // TODO: find a better way to inspect the navigator stack + navigator.popUntil((route) { + isEmptyStack = false; + return true; // never actually pops + }); + if (isEmptyStack) { + unawaited(navigator.push( + MaterialWidgetRoute(page: const ChooseAccountPage()))); + } + } + + @override + void didRemove(Route route, Route? previousRoute) async { + _pushRouteIfEmptyStack(); + } + + @override + void didPop(Route route, Route? previousRoute) async { + _pushRouteIfEmptyStack(); + } +} + class ChooseAccountPage extends StatelessWidget { const ChooseAccountPage({super.key}); diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index fd198a9d7c..e34c5b34e0 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -4,6 +4,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/log.dart'; +import 'package:zulip/model/actions.dart'; import 'package:zulip/model/database.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/home.dart'; @@ -57,6 +58,42 @@ void main() { }); }); + group('_PreventEmptyStack', () { + late List> pushedRoutes; + late List> removedRoutes; + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + + pushedRoutes = []; + removedRoutes = []; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); + testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); + + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); // start to load account + check(pushedRoutes).single.isA().page.isA(); + pushedRoutes.clear(); + } + + testWidgets('push route when removing last route on stack', (tester) async { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + // The navigator stack should contain only a home page route. + + // Log out, causing the home page to be removed from the stack. + final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); + await tester.pump(TestGlobalStore.removeAccountDuration); + await future; + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + // The choose-account page should appear. + check(removedRoutes).single.isA().page.isA(); + check(pushedRoutes).single.isA().page.isA(); + }); + }); + group('ChooseAccountPage', () { Future setupChooseAccountPage(WidgetTester tester, { required List accounts, From 740efb475b21357992db55c95e4fc0a227b89987 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 13 Feb 2025 18:23:23 -0500 Subject: [PATCH 081/110] store [nfc]: Mention TODO for checking account existence Signed-off-by: Zixuan James Li --- lib/model/store.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/model/store.dart b/lib/model/store.dart index d7c4632959..aae72f4650 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -151,6 +151,7 @@ abstract class GlobalStore extends ChangeNotifier { assert(_accounts.containsKey(accountId)); final store = await doLoadPerAccount(accountId); if (!_accounts.containsKey(accountId)) { + // TODO(#1354): handle this earlier // [removeAccount] was called during [doLoadPerAccount]. store.dispose(); throw AccountNotFoundException(); From 938b5305524a65852c1dd37733d394f5912d495b Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 18 Feb 2025 12:22:23 -0500 Subject: [PATCH 082/110] test [nfc]: Introduce apiExceptionBadEventQueueId This is another common type of error that is expected to be reused more later Signed-off-by: Zixuan James Li --- test/example_data.dart | 12 ++++++++++++ test/model/store_test.dart | 7 ++----- test/widgets/message_list_test.dart | 6 ++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index 116a9a1bd8..9e15ce940b 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -39,6 +39,18 @@ ZulipApiException apiBadRequest({ data: {}, message: message); } +/// The error for the "events" route when the target event queue has been +/// garbage collected. +/// +/// https://zulip.com/api/get-events#bad_event_queue_id-errors +ZulipApiException apiExceptionBadEventQueueId({ + String queueId = 'fb67bf8a-c031-47cc-84cf-ed80accacda8', +}) { + return ZulipApiException( + routeName: 'events', httpStatus: 400, code: 'BAD_EVENT_QUEUE_ID', + data: {'queue_id': queueId}, message: 'Bad event queue ID: $queueId'); +} + /// The error the server gives when the client's credentials /// (API key together with email and realm URL) are no longer valid. /// diff --git a/test/model/store_test.dart b/test/model/store_test.dart index ad80a6263d..dc37c2c786 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -757,11 +757,8 @@ void main() { } void prepareExpiredEventQueue() { - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_EVENT_QUEUE_ID', - 'queue_id': updateMachine.queueId, - 'msg': 'Bad event queue ID: ${updateMachine.queueId}', - }); + connection.prepare(apiException: eg.apiExceptionBadEventQueueId( + queueId: updateMachine.queueId)); } Future prepareHandleEventError() async { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 58782a30ec..1aa1af81c5 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -7,7 +7,6 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -143,9 +142,8 @@ void main() { updateMachine.debugPauseLoop(); updateMachine.poll(); - updateMachine.debugPrepareLoopError(ZulipApiException( - routeName: 'events', httpStatus: 400, code: 'BAD_EVENT_QUEUE_ID', - data: {'queue_id': updateMachine.queueId}, message: 'Bad event queue ID.')); + updateMachine.debugPrepareLoopError( + eg.apiExceptionBadEventQueueId(queueId: updateMachine.queueId)); updateMachine.debugAdvanceLoop(); await tester.pump(); // Event queue has been replaced; but the [MessageList] hasn't been From 9d69074c4f3eb22cbd64fafb2c49f9cdb6326b65 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 13 Feb 2025 13:15:43 -0500 Subject: [PATCH 083/110] store: Expect AccountNotFoundException when reloading store This can happen when `removeAccount` was called during `doLoadPerAccount`, possibly due to the user logging out from choose-account page while the store is getting reloaded. However, it is currently not reachable live because of a bug in `UpdateMachine.load`. See #1354. Modulo the bug, `UpdateMachine` can safely ignore this error acknowledging that the reload has failed, given that the user can continue using the app by navigating from choose-account page. Signed-off-by: Zixuan James Li --- lib/model/store.dart | 10 +++++-- test/example_data.dart | 9 ++++-- test/model/store_test.dart | 60 ++++++++++++++++++++++++++++++++++++++ test/stdlib_checks.dart | 5 ++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index aae72f4650..002a7c1ce4 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -1220,8 +1220,14 @@ class UpdateMachine { if (_disposed) return; } - await store._globalStore._reloadPerAccount(store.accountId); - assert(_disposed); + try { + await store._globalStore._reloadPerAccount(store.accountId); + } on AccountNotFoundException { + assert(debugLog('… Event queue not replaced; account was logged out.')); + return; + } finally { + assert(_disposed); + } assert(debugLog('… Event queue replaced.')); } diff --git a/test/example_data.dart b/test/example_data.dart index 9e15ce940b..a758481f52 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -879,6 +879,7 @@ ChannelUpdateEvent channelUpdateEvent( TestGlobalStore globalStore({List accounts = const []}) { return TestGlobalStore(accounts: accounts); } +const _globalStore = globalStore; InitialSnapshot initialSnapshot({ String? queueId, @@ -949,10 +950,14 @@ InitialSnapshot initialSnapshot({ } const _initialSnapshot = initialSnapshot; -PerAccountStore store({Account? account, InitialSnapshot? initialSnapshot}) { +PerAccountStore store({ + GlobalStore? globalStore, + Account? account, + InitialSnapshot? initialSnapshot, +}) { final effectiveAccount = account ?? selfAccount; return PerAccountStore.fromInitialSnapshot( - globalStore: globalStore(accounts: [effectiveAccount]), + globalStore: globalStore ?? _globalStore(accounts: [effectiveAccount]), accountId: effectiveAccount.id, initialSnapshot: initialSnapshot ?? _initialSnapshot(), ); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index dc37c2c786..59ba4bd8ff 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -13,6 +13,7 @@ import 'package:zulip/api/route/events.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/realm.dart'; import 'package:zulip/log.dart'; +import 'package:zulip/model/actions.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/receive.dart'; @@ -969,6 +970,65 @@ void main() { }); }); + group('UpdateMachine.poll reload failure', () { + late LoadingTestGlobalStore globalStore; + + List> completers() => + globalStore.completers[eg.selfAccount.id]!; + + Future prepareReload(FakeAsync async) async { + globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]); + final future = globalStore.perAccount(eg.selfAccount.id); + final store = eg.store(globalStore: globalStore, account: eg.selfAccount); + completers().single.complete(store); + await future; + completers().clear(); + final updateMachine = globalStore.updateMachines[eg.selfAccount.id] = + UpdateMachine.fromInitialSnapshot( + store: store, initialSnapshot: eg.initialSnapshot()); + updateMachine.debugPauseLoop(); + updateMachine.poll(); + + (store.connection as FakeApiConnection).prepare( + apiException: eg.apiExceptionBadEventQueueId()); + updateMachine.debugAdvanceLoop(); + async.elapse(Duration.zero); + check(store).isLoading.isTrue(); + } + + void checkReloadFailure({ + required Future Function() completeLoading, + }) { + awaitFakeAsync((async) async { + await prepareReload(async); + check(completers()).single.isCompleted.isFalse(); + + await completeLoading(); + check(completers()).single.isCompleted.isTrue(); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + }); + } + + Future logOutAndCompleteWithNewStore() async { + // [PerAccountStore.fromInitialSnapshot] requires the account + // to be in the global store when called; do so before logging out. + final newStore = eg.store(globalStore: globalStore, account: eg.selfAccount); + await logOutAccount(globalStore, eg.selfAccount.id); + completers().single.complete(newStore); + } + + test('user logged out before new store is loaded', () => awaitFakeAsync((async) async { + checkReloadFailure(completeLoading: logOutAndCompleteWithNewStore); + })); + }); + group('UpdateMachine.registerNotificationToken', () { late UpdateMachine updateMachine; late FakeApiConnection connection; diff --git a/test/stdlib_checks.dart b/test/stdlib_checks.dart index 792588ee75..8bfaea54fd 100644 --- a/test/stdlib_checks.dart +++ b/test/stdlib_checks.dart @@ -5,6 +5,7 @@ /// part of the Dart standard library. library; +import 'dart:async'; import 'dart:convert'; import 'package:checks/checks.dart'; @@ -74,6 +75,10 @@ Object? deepToJson(Object? object) { return (result, true); } +extension CompleterChecks on Subject> { + Subject get isCompleted => has((x) => x.isCompleted, 'isCompleted'); +} + extension JsonChecks on Subject { /// Expects that the value is deeply equal to [expected], /// after calling [deepToJson] on both. From 7cd25f2730b6fabc4c6c74b7bc25bfeeafa2ba82 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 19 Dec 2024 16:29:41 -0500 Subject: [PATCH 084/110] store: Handle invalid API key on register-queue The method loadPerAccount has two call sites, i.e. places where we send register-queue requests: 1. _reloadPerAccount through [UpdateMachine._handlePollError] (e.g.: expired event queue) 2. perAccount through [PerAccountStoreWidget] (e.g.: loading for the first time) Both sites already expect [AccountNotFoundException] by assuming that the `loadPerAccount` fail is irrecoverable and is handled elsewhere. This partly addresses #890 by handling authentication errors for register-queue. Fixes: #737 Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 7 ++ lib/generated/l10n/zulip_localizations.dart | 6 ++ .../l10n/zulip_localizations_ar.dart | 5 ++ .../l10n/zulip_localizations_en.dart | 5 ++ .../l10n/zulip_localizations_ja.dart | 5 ++ .../l10n/zulip_localizations_nb.dart | 5 ++ .../l10n/zulip_localizations_pl.dart | 5 ++ .../l10n/zulip_localizations_ru.dart | 5 ++ .../l10n/zulip_localizations_sk.dart | 5 ++ lib/model/store.dart | 47 +++++++++-- test/model/store_test.dart | 33 +++++++- test/model/test_store.dart | 4 + test/widgets/app_test.dart | 82 +++++++++++++++++++ 13 files changed, 207 insertions(+), 7 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index f83ab27199..118ab83c70 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -523,6 +523,13 @@ "@topicValidationErrorMandatoryButEmpty": { "description": "Topic validation error when topic is required but was empty." }, + "errorInvalidApiKeyMessage": "Your account at {url} could not be authenticated. Please try logging in again or use another account.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": {"type": "String", "example": "http://chat.example.com/"} + } + }, "errorInvalidResponse": "The server sent an invalid response", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 3946112973..9579683908 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -801,6 +801,12 @@ abstract class ZulipLocalizations { /// **'Topics are required in this organization.'** String get topicValidationErrorMandatoryButEmpty; + /// Error message in the dialog for invalid API key. + /// + /// In en, this message translates to: + /// **'Your account at {url} could not be authenticated. Please try logging in again or use another account.'** + String errorInvalidApiKeyMessage(String url); + /// Error message when an API call returned an invalid response. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 07983fe188..71bf06d8ce 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -404,6 +404,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index b1f5cdaf8f..7a33e33567 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -404,6 +404,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 258e51dce0..137883e5e9 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -404,6 +404,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 95650fe317..3dec7d9b5a 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -404,6 +404,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index c8151c8daf..83a777bfc4 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -404,6 +404,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.'; + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + @override String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 876f2ad89a..827aaf0155 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -404,6 +404,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.'; + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + @override String get errorInvalidResponse => 'Получен недопустимый ответ сервера'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index d59c8456c0..38a3f8a240 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -404,6 +404,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorInvalidApiKeyMessage(String url) { + return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + } + @override String get errorInvalidResponse => 'Server poslal nesprávnu odpoveď'; diff --git a/lib/model/store.dart b/lib/model/store.dart index 002a7c1ce4..611494cc8f 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -19,6 +19,7 @@ import '../api/backoff.dart'; import '../api/route/realm.dart'; import '../log.dart'; import '../notifications/receive.dart'; +import 'actions.dart'; import 'autocomplete.dart'; import 'database.dart'; import 'emoji.dart'; @@ -149,7 +150,34 @@ abstract class GlobalStore extends ChangeNotifier { /// and/or [perAccountSync]. Future loadPerAccount(int accountId) async { assert(_accounts.containsKey(accountId)); - final store = await doLoadPerAccount(accountId); + final PerAccountStore store; + try { + store = await doLoadPerAccount(accountId); + } catch (e) { + switch (e) { + case HttpException(httpStatus: 401): + // The API key is invalid and the store can never be loaded + // unless the user retries manually. + final account = getAccount(accountId); + if (account == null) { + // The account was logged out during `await doLoadPerAccount`. + // Here, that seems possible only by the user's own action; + // the logout can't have been done programmatically. + // Even if it were, it would have come with its own UI feedback. + // Anyway, skip showing feedback, to not be confusing or repetitive. + throw AccountNotFoundException(); + } + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + reportErrorToUserModally( + zulipLocalizations.errorCouldNotConnectTitle, + message: zulipLocalizations.errorInvalidApiKeyMessage( + account.realmUrl.toString())); + await logOutAccount(this, accountId); + throw AccountNotFoundException(); + default: + rethrow; + } + } if (!_accounts.containsKey(accountId)) { // TODO(#1354): handle this earlier // [removeAccount] was called during [doLoadPerAccount]. @@ -914,12 +942,19 @@ class UpdateMachine { try { return await registerQueue(connection); } catch (e, s) { - assert(debugLog('Error fetching initial snapshot: $e')); - // Print stack trace in its own log entry; log entries are truncated - // at 1 kiB (at least on Android), and stack can be longer than that. - assert(debugLog('Stack:\n$s')); - assert(debugLog('Backing off, then will retry…')); // TODO(#890): tell user if initial-fetch errors persist, or look non-transient + switch (e) { + case HttpException(httpStatus: 401): + // We cannot recover from this error through retrying. + // Leave it to [GlobalStore.loadPerAccount]. + rethrow; + default: + assert(debugLog('Error fetching initial snapshot: $e')); + // Print stack trace in its own log entry; log entries are truncated + // at 1 kiB (at least on Android), and stack can be longer than that. + assert(debugLog('Stack:\n$s')); + } + assert(debugLog('Backing off, then will retry…')); await (backoffMachine ??= BackoffMachine()).wait(); assert(debugLog('… Backoff wait complete, retrying initial fetch.')); } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 59ba4bd8ff..99235dbe67 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -121,6 +121,29 @@ void main() { check(completers(1)).length.equals(1); }); + test('GlobalStore.perAccount loading fails with HTTP status code 401', () => awaitFakeAsync((async) async { + final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]); + final future = globalStore.perAccount(eg.selfAccount.id); + + globalStore.completers[eg.selfAccount.id]! + .single.completeError(eg.apiExceptionUnauthorized()); + await check(future).throws(); + })); + + test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async { + final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]); + final future = globalStore.perAccount(eg.selfAccount.id); + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + + globalStore.completers[eg.selfAccount.id]! + .single.completeError(eg.apiExceptionUnauthorized()); + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + })); + // TODO test insertAccount group('GlobalStore.updateAccount', () { @@ -997,7 +1020,7 @@ void main() { } void checkReloadFailure({ - required Future Function() completeLoading, + required FutureOr Function() completeLoading, }) { awaitFakeAsync((async) async { await prepareReload(async); @@ -1027,6 +1050,14 @@ void main() { test('user logged out before new store is loaded', () => awaitFakeAsync((async) async { checkReloadFailure(completeLoading: logOutAndCompleteWithNewStore); })); + + void completeWithApiExceptionUnauthorized() { + completers().single.completeError(eg.apiExceptionUnauthorized()); + } + + test('new store is not loaded, gets HTTP 401 error instead', () => awaitFakeAsync((async) async { + checkReloadFailure(completeLoading: completeWithApiExceptionUnauthorized); + })); }); group('UpdateMachine.registerNotificationToken', () { diff --git a/test/model/test_store.dart b/test/model/test_store.dart index b6887cbed7..534a6003b5 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -129,6 +129,7 @@ class TestGlobalStore extends GlobalStore { static const Duration removeAccountDuration = Duration(milliseconds: 1); Duration? loadPerAccountDuration; + Object? loadPerAccountException; /// Consume the log of calls made to [doRemoveAccount]. List takeDoRemoveAccountCalls() { @@ -150,6 +151,9 @@ class TestGlobalStore extends GlobalStore { if (loadPerAccountDuration != null) { await Future.delayed(loadPerAccountDuration!); } + if (loadPerAccountException != null) { + throw loadPerAccountException!; + } final initialSnapshot = _initialSnapshots[accountId]!; final store = PerAccountStore.fromInitialSnapshot( globalStore: this, diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index e34c5b34e0..7b1388dbec 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -61,15 +61,18 @@ void main() { group('_PreventEmptyStack', () { late List> pushedRoutes; late List> removedRoutes; + late List> poppedRoutes; Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); pushedRoutes = []; removedRoutes = []; + poppedRoutes = []; final testNavObserver = TestNavigatorObserver(); testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); + testNavObserver.onPopped = (route, prevRoute) => poppedRoutes.add(route); await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); await tester.pump(); // start to load account @@ -92,6 +95,85 @@ void main() { check(removedRoutes).single.isA().page.isA(); check(pushedRoutes).single.isA().page.isA(); }); + + testWidgets('push route when popping last route on stack', (tester) async { + // Set up the loading of per-account data to fail. + await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + testBinding.globalStore.loadPerAccountDuration = Duration.zero; + testBinding.globalStore.loadPerAccountException = eg.apiExceptionUnauthorized(); + await prepare(tester); + // The navigator stack should contain only a home page route. + + // Await the failed load, causing the home page to be removed + // and an error dialog pushed in its place. + await tester.pump(Duration.zero); + await tester.pump(TestGlobalStore.removeAccountDuration); + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + check(removedRoutes).single.isA().page.isA(); + check(poppedRoutes).isEmpty(); + check(pushedRoutes).single.isA>(); + pushedRoutes.clear(); + + // Dismiss the error dialog, causing it to be popped from the stack. + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Could not connect', + expectedMessage: + 'Your account at ${eg.selfAccount.realmUrl} could not be authenticated.' + ' Please try logging in again or use another account.'))); + // The choose-account page should appear, because the error dialog + // was the only route remaining. + check(poppedRoutes).single.isA>(); + check(pushedRoutes).single.isA().page.isA(); + }); + + testWidgets('do not push route to non-empty navigator stack', (tester) async { + // Set up the loading of per-account data to fail, but only after a + // long enough time for the "Try another account" button to appear. + const loadPerAccountDuration = Duration(seconds: 30); + assert(loadPerAccountDuration > kTryAnotherAccountWaitPeriod); + await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + testBinding.globalStore.loadPerAccountException = eg.apiExceptionUnauthorized(); + await prepare(tester); + // The navigator stack should contain only a home page route. + + // Await the "Try another account" button, and tap it. + await tester.pump(kTryAnotherAccountWaitPeriod); + await tester.tap(find.text('Try another account')); + await tester.pump(); + // The navigator stack should contain the home page route + // and a choose-account page route. + check(removedRoutes).isEmpty(); + check(poppedRoutes).isEmpty(); + check(pushedRoutes).single.isA().page.isA(); + pushedRoutes.clear(); + + // Now await the failed load, causing the home page to be removed + // and an error dialog pushed, while the choose-account page remains. + await tester.pump(loadPerAccountDuration); + await tester.pump(TestGlobalStore.removeAccountDuration); + check(testBinding.globalStore.takeDoRemoveAccountCalls()) + .single.equals(eg.selfAccount.id); + check(removedRoutes).single.isA().page.isA(); + check(poppedRoutes).isEmpty(); + check(pushedRoutes).single.isA>(); + pushedRoutes.clear(); + // The navigator stack should now contain the choose-account page route + // and the dialog route. + + // Dismiss the error dialog, causing it to be popped from the stack. + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Could not connect', + expectedMessage: + 'Your account at ${eg.selfAccount.realmUrl} could not be authenticated.' + ' Please try logging in again or use another account.'))); + // No routes should be pushed after dismissing the error dialog, + // because there was already another route remaining on the stack + // (namely the choose-account page route). + check(poppedRoutes).single.isA>(); + check(pushedRoutes).isEmpty(); + }); }); group('ChooseAccountPage', () { From 4f438d341f52ec8635959e3a5233c8eb1931a0e6 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Wed, 5 Feb 2025 15:00:26 -0800 Subject: [PATCH 085/110] home: Center align the "try another account" message in loading placeholder Fixes #1246. --- lib/widgets/home.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index d7b1585022..160544c5c8 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -212,7 +212,8 @@ class _LoadingPlaceholderPageState extends State<_LoadingPlaceholderPage> { child: Column( children: [ const SizedBox(height: 16), - Text(zulipLocalizations.tryAnotherAccountMessage(account.realmUrl.toString())), + Text(textAlign: TextAlign.center, + zulipLocalizations.tryAnotherAccountMessage(account.realmUrl.toString())), const SizedBox(height: 8), ElevatedButton( onPressed: () => Navigator.push(context, From 3017ba6eb18ec8cc9759d5cffd489e3d49085d3a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 18 Feb 2025 20:21:24 -0800 Subject: [PATCH 086/110] actions [nfc]: Namespace actions as statics on a ZulipAction class This makes it explicit at each call site that the method being called is an action, not (for example) an API endpoint binding. The difference is important in particular because it affects how -- really, whether -- the caller should handle errors. See discussion from when the similarity in appearance to API endpoint bindings caused confusion: https://chat.zulip.org/#narrow/channel/516-mobile-dev-help/topic/.23F1317.20showErrorDialog/near/2080570 --- lib/widgets/action_sheet.dart | 3 +- lib/widgets/actions.dart | 390 ++++++++++++++++----------------- lib/widgets/message_list.dart | 2 +- test/widgets/actions_test.dart | 18 +- 4 files changed, 207 insertions(+), 206 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 7c3ea6e622..e943d7eb36 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -796,7 +796,8 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { @override void onPressed() async { final narrow = findMessageListPage().narrow; - unawaited(markNarrowAsUnreadFromMessage(pageContext, message, narrow)); + unawaited(ZulipAction.markNarrowAsUnreadFromMessage(pageContext, + message, narrow)); } } diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index 2df502ec6c..b9fbcbedc6 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -1,11 +1,3 @@ -/// Methods that act through the Zulip API and show feedback in the UI. -/// -/// The methods in this file can be thought of as higher-level wrappers for -/// some of the Zulip API endpoint binding methods in `lib/api/route/`. -/// But they don't belong in `lib/api/`, because they also interact with widgets -/// in order to present success or error feedback to the user through the UI. -library; - import 'dart:async'; import 'package:flutter/material.dart'; @@ -18,206 +10,214 @@ import '../model/narrow.dart'; import 'dialog.dart'; import 'store.dart'; -Future markNarrowAsRead(BuildContext context, Narrow narrow) async { - final store = PerAccountStoreWidget.of(context); - final connection = store.connection; - final zulipLocalizations = ZulipLocalizations.of(context); - final useLegacy = connection.zulipFeatureLevel! < 155; // TODO(server-6) - if (useLegacy) { - try { - await _legacyMarkNarrowAsRead(context, narrow); - return; - } catch (e) { - if (!context.mounted) return; - showErrorDialog(context: context, - title: zulipLocalizations.errorMarkAsReadFailedTitle, - message: e.toString()); // TODO(#741): extract user-facing message better - return; +/// Methods that act through the Zulip API and show feedback in the UI. +/// +/// The static methods on this class can be thought of as higher-level wrappers +/// for some of the Zulip API endpoint binding methods in `lib/api/route/`. +/// But they don't belong in `lib/api/`, because they also interact with widgets +/// in order to present success or error feedback to the user through the UI. +abstract final class ZulipAction { + static Future markNarrowAsRead(BuildContext context, Narrow narrow) async { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + final zulipLocalizations = ZulipLocalizations.of(context); + final useLegacy = connection.zulipFeatureLevel! < 155; // TODO(server-6) + if (useLegacy) { + try { + await _legacyMarkNarrowAsRead(context, narrow); + return; + } catch (e) { + if (!context.mounted) return; + showErrorDialog(context: context, + title: zulipLocalizations.errorMarkAsReadFailedTitle, + message: e.toString()); // TODO(#741): extract user-facing message better + return; + } } - } - final didPass = await updateMessageFlagsStartingFromAnchor( - context: context, - // Include `is:unread` in the narrow. That has a database index, so - // this can be an important optimization in narrows with a lot of history. - // The server applies the same optimization within the (deprecated) - // specialized endpoints for marking messages as read; see - // `do_mark_stream_messages_as_read` in `zulip:zerver/actions/message_flags.py`. - apiNarrow: narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)), - // Use [AnchorCode.oldest], because [AnchorCode.firstUnread] - // will be the oldest non-muted unread message, which would - // result in muted unreads older than the first unread not - // being processed. - anchor: AnchorCode.oldest, - // [AnchorCode.oldest] is an anchor ID lower than any valid - // message ID. - includeAnchor: false, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read, - onCompletedMessage: zulipLocalizations.markAsReadComplete, - progressMessage: zulipLocalizations.markAsReadInProgress, - onFailedTitle: zulipLocalizations.errorMarkAsReadFailedTitle); + final didPass = await updateMessageFlagsStartingFromAnchor( + context: context, + // Include `is:unread` in the narrow. That has a database index, so + // this can be an important optimization in narrows with a lot of history. + // The server applies the same optimization within the (deprecated) + // specialized endpoints for marking messages as read; see + // `do_mark_stream_messages_as_read` in `zulip:zerver/actions/message_flags.py`. + apiNarrow: narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)), + // Use [AnchorCode.oldest], because [AnchorCode.firstUnread] + // will be the oldest non-muted unread message, which would + // result in muted unreads older than the first unread not + // being processed. + anchor: AnchorCode.oldest, + // [AnchorCode.oldest] is an anchor ID lower than any valid + // message ID. + includeAnchor: false, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read, + onCompletedMessage: zulipLocalizations.markAsReadComplete, + progressMessage: zulipLocalizations.markAsReadInProgress, + onFailedTitle: zulipLocalizations.errorMarkAsReadFailedTitle); - if (!didPass || !context.mounted) return; - if (narrow is CombinedFeedNarrow) { - PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess(); + if (!didPass || !context.mounted) return; + if (narrow is CombinedFeedNarrow) { + PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess(); + } } -} -Future markNarrowAsUnreadFromMessage( - BuildContext context, - Message message, - Narrow narrow, -) async { - final connection = PerAccountStoreWidget.of(context).connection; - assert(connection.zulipFeatureLevel! >= 155); // TODO(server-6) - final zulipLocalizations = ZulipLocalizations.of(context); - await updateMessageFlagsStartingFromAnchor( - context: context, - apiNarrow: narrow.apiEncode(), - anchor: NumericAnchor(message.id), - includeAnchor: true, - op: UpdateMessageFlagsOp.remove, - flag: MessageFlag.read, - onCompletedMessage: zulipLocalizations.markAsUnreadComplete, - progressMessage: zulipLocalizations.markAsUnreadInProgress, - onFailedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle); -} + static Future markNarrowAsUnreadFromMessage( + BuildContext context, + Message message, + Narrow narrow, + ) async { + final connection = PerAccountStoreWidget.of(context).connection; + assert(connection.zulipFeatureLevel! >= 155); // TODO(server-6) + final zulipLocalizations = ZulipLocalizations.of(context); + await updateMessageFlagsStartingFromAnchor( + context: context, + apiNarrow: narrow.apiEncode(), + anchor: NumericAnchor(message.id), + includeAnchor: true, + op: UpdateMessageFlagsOp.remove, + flag: MessageFlag.read, + onCompletedMessage: zulipLocalizations.markAsUnreadComplete, + progressMessage: zulipLocalizations.markAsUnreadInProgress, + onFailedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle); + } -/// Add or remove the given flag from the anchor to the end of the narrow, -/// showing feedback to the user on progress or failure. -/// -/// This has the semantics of [updateMessageFlagsForNarrow] -/// (see https://zulip.com/api/update-message-flags-for-narrow) -/// with `numBefore: 0` and infinite `numAfter`. It operates by calling that -/// endpoint with a finite `numAfter` as a batch size, in a loop. -/// -/// If the operation requires more than one batch, the user is shown progress -/// feedback through [SnackBar], using [progressMessage] and [onCompletedMessage]. -/// If the operation fails, the user is shown an error dialog box with title -/// [onFailedTitle]. -/// -/// Returns true just if the operation finished successfully. -Future updateMessageFlagsStartingFromAnchor({ - required BuildContext context, - required List apiNarrow, - required Anchor anchor, - required bool includeAnchor, - required UpdateMessageFlagsOp op, - required MessageFlag flag, - required String Function(int) onCompletedMessage, - required String progressMessage, - required String onFailedTitle, -}) async { - try { - final store = PerAccountStoreWidget.of(context); - final connection = store.connection; - final scaffoldMessenger = ScaffoldMessenger.of(context); + /// Add or remove the given flag from the anchor to the end of the narrow, + /// showing feedback to the user on progress or failure. + /// + /// This has the semantics of [updateMessageFlagsForNarrow] + /// (see https://zulip.com/api/update-message-flags-for-narrow) + /// with `numBefore: 0` and infinite `numAfter`. It operates by calling that + /// endpoint with a finite `numAfter` as a batch size, in a loop. + /// + /// If the operation requires more than one batch, the user is shown progress + /// feedback through [SnackBar], using [progressMessage] and [onCompletedMessage]. + /// If the operation fails, the user is shown an error dialog box with title + /// [onFailedTitle]. + /// + /// Returns true just if the operation finished successfully. + static Future updateMessageFlagsStartingFromAnchor({ + required BuildContext context, + required List apiNarrow, + required Anchor anchor, + required bool includeAnchor, + required UpdateMessageFlagsOp op, + required MessageFlag flag, + required String Function(int) onCompletedMessage, + required String progressMessage, + required String onFailedTitle, + }) async { + try { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + final scaffoldMessenger = ScaffoldMessenger.of(context); - // Compare web's `mark_all_as_read` in web/src/unread_ops.js - // and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js . - int responseCount = 0; - int updatedCount = 0; - while (true) { - final result = await updateMessageFlagsForNarrow(connection, - anchor: anchor, - includeAnchor: includeAnchor, - // There is an upper limit of 5000 messages per batch - // (numBefore + numAfter <= 5000) enforced on the server. - // See `update_message_flags_in_narrow` in zerver/views/message_flags.py . - // zulip-mobile uses `numAfter` of 5000, but web uses 1000 - // for more responsive feedback. See zulip@f0d87fcf6. - numBefore: 0, - numAfter: 1000, - narrow: apiNarrow, - op: op, - flag: flag); - if (!context.mounted) { - scaffoldMessenger.clearSnackBars(); - return false; - } - responseCount++; - updatedCount += result.updatedCount; + // Compare web's `mark_all_as_read` in web/src/unread_ops.js + // and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js . + int responseCount = 0; + int updatedCount = 0; + while (true) { + final result = await updateMessageFlagsForNarrow(connection, + anchor: anchor, + includeAnchor: includeAnchor, + // There is an upper limit of 5000 messages per batch + // (numBefore + numAfter <= 5000) enforced on the server. + // See `update_message_flags_in_narrow` in zerver/views/message_flags.py . + // zulip-mobile uses `numAfter` of 5000, but web uses 1000 + // for more responsive feedback. See zulip@f0d87fcf6. + numBefore: 0, + numAfter: 1000, + narrow: apiNarrow, + op: op, + flag: flag); + if (!context.mounted) { + scaffoldMessenger.clearSnackBars(); + return false; + } + responseCount++; + updatedCount += result.updatedCount; - if (result.foundNewest) { - if (responseCount > 1) { - // We previously showed an in-progress [SnackBar], so say we're done. - // There may be a backlog of [SnackBar]s accumulated in the queue - // so be sure to clear them out here. - scaffoldMessenger - ..clearSnackBars() - ..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, - content: Text(onCompletedMessage(updatedCount)))); + if (result.foundNewest) { + if (responseCount > 1) { + // We previously showed an in-progress [SnackBar], so say we're done. + // There may be a backlog of [SnackBar]s accumulated in the queue + // so be sure to clear them out here. + scaffoldMessenger + ..clearSnackBars() + ..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, + content: Text(onCompletedMessage(updatedCount)))); + } + return true; } - return true; - } - if (result.lastProcessedId == null) { - final zulipLocalizations = ZulipLocalizations.of(context); - // No messages were in the range of the request. - // This should be impossible given that `foundNewest` was false - // (and that our `numAfter` was positive.) - showErrorDialog(context: context, - title: onFailedTitle, - message: zulipLocalizations.errorInvalidResponse); - return false; - } - anchor = NumericAnchor(result.lastProcessedId!); - includeAnchor = false; + if (result.lastProcessedId == null) { + final zulipLocalizations = ZulipLocalizations.of(context); + // No messages were in the range of the request. + // This should be impossible given that `foundNewest` was false + // (and that our `numAfter` was positive.) + showErrorDialog(context: context, + title: onFailedTitle, + message: zulipLocalizations.errorInvalidResponse); + return false; + } + anchor = NumericAnchor(result.lastProcessedId!); + includeAnchor = false; - // The task is taking a while, so tell the user we're working on it. - // TODO: Ideally we'd have a progress widget here that showed up based - // on actual time elapsed -- so it could appear before the first - // batch returns, if that takes a while -- and that then stuck - // around continuously until the task ends. For now we use a - // series of [SnackBar]s, which may feel a bit janky. - // There is complexity in tracking the status of each [SnackBar], - // due to having no way to determine which is currently active, - // or if there is an active one at all. Resetting the [SnackBar] here - // results in the same message popping in and out and the user experience - // is better for now if we allow them to run their timer through - // and clear the backlog later. - scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, - content: Text(progressMessage))); + // The task is taking a while, so tell the user we're working on it. + // TODO: Ideally we'd have a progress widget here that showed up based + // on actual time elapsed -- so it could appear before the first + // batch returns, if that takes a while -- and that then stuck + // around continuously until the task ends. For now we use a + // series of [SnackBar]s, which may feel a bit janky. + // There is complexity in tracking the status of each [SnackBar], + // due to having no way to determine which is currently active, + // or if there is an active one at all. Resetting the [SnackBar] here + // results in the same message popping in and out and the user experience + // is better for now if we allow them to run their timer through + // and clear the backlog later. + scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, + content: Text(progressMessage))); + } + } catch (e) { + if (!context.mounted) return false; + showErrorDialog(context: context, + title: onFailedTitle, + message: e.toString()); // TODO(#741): extract user-facing message better + return false; } - } catch (e) { - if (!context.mounted) return false; - showErrorDialog(context: context, - title: onFailedTitle, - message: e.toString()); // TODO(#741): extract user-facing message better - return false; } -} -Future _legacyMarkNarrowAsRead(BuildContext context, Narrow narrow) async { - final store = PerAccountStoreWidget.of(context); - final connection = store.connection; - switch (narrow) { - case CombinedFeedNarrow(): - await markAllAsRead(connection); - case ChannelNarrow(:final streamId): - await markStreamAsRead(connection, streamId: streamId); - case TopicNarrow(:final streamId, :final topic): - await markTopicAsRead(connection, streamId: streamId, topicName: topic); - case DmNarrow(): - final unreadDms = store.unreads.dms[narrow]; - // Silently ignore this race-condition as the outcome - // (no unreads in this narrow) was the desired end-state - // of pushing the button. - if (unreadDms == null) return; - await updateMessageFlags(connection, - messages: unreadDms, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read); - case MentionsNarrow(): - final unreadMentions = store.unreads.mentions.toList(); - if (unreadMentions.isEmpty) return; - await updateMessageFlags(connection, - messages: unreadMentions, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read); - case StarredMessagesNarrow(): - // TODO: Implement unreads handling. - return; + static Future _legacyMarkNarrowAsRead(BuildContext context, Narrow narrow) async { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + switch (narrow) { + case CombinedFeedNarrow(): + await markAllAsRead(connection); + case ChannelNarrow(:final streamId): + await markStreamAsRead(connection, streamId: streamId); + case TopicNarrow(:final streamId, :final topic): + await markTopicAsRead(connection, streamId: streamId, topicName: topic); + case DmNarrow(): + final unreadDms = store.unreads.dms[narrow]; + // Silently ignore this race-condition as the outcome + // (no unreads in this narrow) was the desired end-state + // of pushing the button. + if (unreadDms == null) return; + await updateMessageFlags(connection, + messages: unreadDms, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read); + case MentionsNarrow(): + final unreadMentions = store.unreads.mentions.toList(); + if (unreadMentions.isEmpty) return; + await updateMessageFlags(connection, + messages: unreadMentions, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read); + case StarredMessagesNarrow(): + // TODO: Implement unreads handling. + return; + } } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 06ecff110f..48c0d70b59 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -803,7 +803,7 @@ class _MarkAsReadWidgetState extends State { void _handlePress(BuildContext context) async { if (!context.mounted) return; setState(() => _loading = true); - await markNarrowAsRead(context, widget.narrow); + await ZulipAction.markNarrowAsRead(context, widget.narrow); setState(() => _loading = false); } diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 9148cf4fbf..4f65515043 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -56,7 +56,7 @@ void main() { processedCount: 11, updatedCount: 3, firstProcessedId: null, lastProcessedId: null, foundOldest: true, foundNewest: true).toJson()); - final future = markNarrowAsRead(context, narrow); + final future = ZulipAction.markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await future; final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); @@ -81,7 +81,7 @@ void main() { processedCount: 11, updatedCount: 3, firstProcessedId: null, lastProcessedId: null, foundOldest: true, foundNewest: true).toJson()); - final future = markNarrowAsRead(context, narrow); + final future = ZulipAction.markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await future; check(connection.lastRequest).isA() @@ -107,7 +107,7 @@ void main() { processedCount: 11, updatedCount: 3, firstProcessedId: null, lastProcessedId: null, foundOldest: true, foundNewest: true).toJson()); - final future = markNarrowAsRead(context, narrow); + final future = ZulipAction.markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await future; check(store.unreads.oldUnreadsMissing).isFalse(); @@ -121,7 +121,7 @@ void main() { connection.zulipFeatureLevel = 154; connection.prepare(json: {}); - final future = markNarrowAsRead(context, narrow); + final future = ZulipAction.markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await future; check(connection.lastRequest).isA() @@ -140,7 +140,7 @@ void main() { await prepare(tester); connection.zulipFeatureLevel = 154; connection.prepare(json: {}); - final future = markNarrowAsRead(context, narrow); + final future = ZulipAction.markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await future; check(connection.lastRequest).isA() @@ -156,7 +156,7 @@ void main() { await prepare(tester); connection.zulipFeatureLevel = 154; connection.prepare(json: {}); - final future = markNarrowAsRead(context, narrow); + final future = ZulipAction.markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await future; check(connection.lastRequest).isA() @@ -179,7 +179,7 @@ void main() { connection.zulipFeatureLevel = 154; connection.prepare(json: UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = markNarrowAsRead(context, narrow); + final future = ZulipAction.markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await future; check(connection.lastRequest).isA() @@ -200,7 +200,7 @@ void main() { connection.zulipFeatureLevel = 154; connection.prepare(json: UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = markNarrowAsRead(context, narrow); + final future = ZulipAction.markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await future; check(connection.lastRequest).isA() @@ -222,7 +222,7 @@ void main() { final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); Future invokeUpdateMessageFlagsStartingFromAnchor() => - updateMessageFlagsStartingFromAnchor( + ZulipAction.updateMessageFlagsStartingFromAnchor( context: context, apiNarrow: apiNarrow, op: UpdateMessageFlagsOp.add, From a198f72ee45d3430001135d94242704e01aa34da Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 18 Feb 2025 20:28:33 -0800 Subject: [PATCH 087/110] actions [nfc]: Document remaining methods Like the previous commit, this hopefully helps avoid confusion about the role of these methods when looking at their call sites. --- lib/widgets/actions.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index b9fbcbedc6..814b56d009 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -17,6 +17,11 @@ import 'store.dart'; /// But they don't belong in `lib/api/`, because they also interact with widgets /// in order to present success or error feedback to the user through the UI. abstract final class ZulipAction { + /// Mark the given narrow as read, + /// showing feedback to the user on progress or failure. + /// + /// This is mostly a wrapper around [updateMessageFlagsStartingFromAnchor]; + /// for details on the UI feedback, see there. static Future markNarrowAsRead(BuildContext context, Narrow narrow) async { final store = PerAccountStoreWidget.of(context); final connection = store.connection; @@ -63,6 +68,11 @@ abstract final class ZulipAction { } } + /// Mark the given narrow as unread from the given message onward, + /// showing feedback to the user on progress or failure. + /// + /// This is a wrapper around [updateMessageFlagsStartingFromAnchor]; + /// for details on the UI feedback, see there. static Future markNarrowAsUnreadFromMessage( BuildContext context, Message message, From d7fbff284e19c882fbb7ee777e7998db64c70b61 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 13 Dec 2024 16:40:54 -0500 Subject: [PATCH 088/110] db [nfc]: Remove dead code Signed-off-by: Zixuan James Li --- lib/model/database.dart | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/model/database.dart b/lib/model/database.dart index 6ca2aa3726..6aa3309c6d 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -1,10 +1,5 @@ -import 'dart:io'; - import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; import 'package:drift/remote.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; import 'package:sqlite3/common.dart'; part 'database.g.dart'; @@ -52,21 +47,10 @@ class UriConverter extends TypeConverter { @override Uri fromSql(String fromDb) => Uri.parse(fromDb); } -LazyDatabase _openConnection() { - return LazyDatabase(() async { - // TODO decide if this path is the right one to use - final dbFolder = await getApplicationDocumentsDirectory(); - final file = File(path.join(dbFolder.path, 'db.sqlite')); - return NativeDatabase.createInBackground(file); - }); -} - @DriftDatabase(tables: [Accounts]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e); - AppDatabase.live() : this(_openConnection()); - // When updating the schema: // * Make the change in the table classes, and bump schemaVersion. // * Export the new schema and generate test migrations: From 5ad630ee1d5fcb7b94f1c1bdcc74b24bb1da9b8c Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 17 Dec 2024 11:45:01 -0500 Subject: [PATCH 089/110] db [nfc]: Mention build_runner for schema changes Signed-off-by: Zixuan James Li --- lib/model/database.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/model/database.dart b/lib/model/database.dart index 6aa3309c6d..6f941c184b 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -53,8 +53,11 @@ class AppDatabase extends _$AppDatabase { // When updating the schema: // * Make the change in the table classes, and bump schemaVersion. - // * Export the new schema and generate test migrations: + // * Export the new schema and generate test migrations with drift: // $ tools/check --fix drift + // and generate database code with build_runner. + // See ../../README.md#generated-files for more + // information on using the build_runner. // * Write a migration in `onUpgrade` below. // * Write tests. @override From 9b2d580fa29eef8d7a46faad6a05dcaf6ffc9c9b Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 17 Dec 2024 19:09:47 -0500 Subject: [PATCH 090/110] db test: Add missing `after.close` call As we add more tests for future migrations, Drift will start complaining about opened databases that are not closed. Always remember doing this will ensure that we don't leak states to other database tests. Signed-off-by: Zixuan James Li --- test/model/database_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/model/database_test.dart b/test/model/database_test.dart index cb3a7d299b..1cdc06251f 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -130,6 +130,7 @@ void main() { ...accountV1.toJson(), 'ackedPushToken': null, }); + await after.close(); }); }); } From d302584e53b332d6b503b4d0a9761d4b5c419069 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Sun, 29 Dec 2024 23:21:33 -0500 Subject: [PATCH 091/110] deps [nfc]: Set minimum version of drift to 2.23.0 We have already upgraded drift; this reflects that we rely on newer drift versions, but does not actually require changes to the lock files. Signed-off-by: Zixuan James Li --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cc1d93cca8..c17bebe704 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: convert: ^3.1.1 crypto: ^3.0.3 device_info_plus: ^11.2.0 - drift: ^2.5.0 + drift: ^2.23.0 file_picker: ^8.0.0+1 firebase_core: ^3.3.0 firebase_messaging: ^15.0.1 From 33da884303773fa12b5597433302698f46158df5 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 3 Jan 2025 11:30:52 +0800 Subject: [PATCH 092/110] db test: Test simple migrations without data programmatically This saves us from writing more simple migration tests between schema versions without data in the future. --- The tests are inspired by the test template generated from `dart run drift_dev make-migrations`. To reproduce the outputs, go through the following steps: Modify `build.yaml` by specifying the location of the database. This step is needed because `make-migrations` does not accept this through command line arguments. ``` targets: $default: builders: # ... drift_dev: options: databases: default: lib/model/database.dart ``` Then, run the following commands: ``` dart run drift_dev make-migrations cp test/model/schemas/*.json drift_schemas/default/ dart run drift_dev make-migrations ``` The first `make-migrations` run generates the initial schema and test files without looking at the versions we have in test/model/schemas. Copying the schema files and running `make-migrations` will end up creating `test/drift/default/migration_test.dart`, along with other generated files. See also: https://drift.simonbinder.eu/Migrations/#usage Signed-off-by: Zixuan James Li --- test/model/database_test.dart | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/test/model/database_test.dart b/test/model/database_test.dart index 1cdc06251f..8b177f9108 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -98,11 +98,30 @@ void main() { verifier = SchemaVerifier(GeneratedHelper()); }); - test('upgrade to v2, empty', () async { - final connection = await verifier.startAt(1); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 2); - await db.close(); + group('migrate without data', () { + const versions = GeneratedHelper.versions; + final latestVersion = versions.last; + + int fromVersion = versions.first; + for (final toVersion in versions.skip(1)) { + test('from v$fromVersion to v$toVersion', () async { + final connection = await verifier.startAt(fromVersion); + final db = AppDatabase(connection); + await verifier.migrateAndValidate(db, toVersion); + await db.close(); + }); + fromVersion = toVersion; + } + + for (final fromVersion in versions) { + if (fromVersion == latestVersion) break; + test('from v$fromVersion to latest (v$latestVersion)', () async { + final connection = await verifier.startAt(fromVersion); + final db = AppDatabase(connection); + await verifier.migrateAndValidate(db, latestVersion); + await db.close(); + }); + } }); test('upgrade to v2, with data', () async { From 3cff3960b93d174ff6df11246c7a2e89749055e5 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 18 Dec 2024 15:46:28 -0500 Subject: [PATCH 093/110] db: Start generating schema versions for migrations Generating schema_versions.g.dart is crucial because we otherwise only have access to the latest schema when running migrations. That would mean that if we later drop or alter a table or column mentioned in an existing migration, the meaning of the existing migration would change. The affected existing migration would then migrate to an unintended state that doesn't match what later migrations expect, and it or a later migration might fail. See also discussion: https://github.com/zulip/zulip-flutter/pull/1248#discussion_r1960988853 --- An alternative to using all these commands for generating files is `dart run drift_dev make-migrations`, which is essentially a wrapper for the `schema {dump,generate,steps}` subcommands. `make-migrations` let us manage multiple database schemas by configuring them with `build.yaml`, and it dictates the which subdirectories the generated files will be created at. Because `make-migrations` does not offer the same level of customizations to designate exactly where the output files will be, opting out from it for now. We can revisit this if it starts to offer features that are not available with the subcommands, or that we find the need for managing multiple databases. See also: https://drift.simonbinder.eu/migrations/step_by_step/#manual-generation Signed-off-by: Zixuan James Li --- lib/model/database.dart | 5 +- lib/model/schema_versions.g.dart | 112 +++++++++++++++++++++++++++++++ tools/check | 11 ++- 3 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 lib/model/schema_versions.g.dart diff --git a/lib/model/database.dart b/lib/model/database.dart index 6f941c184b..2c48badc96 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -2,6 +2,8 @@ import 'package:drift/drift.dart'; import 'package:drift/remote.dart'; import 'package:sqlite3/common.dart'; +import 'schema_versions.g.dart'; + part 'database.g.dart'; /// The table of [Account] records in the app's database. @@ -85,7 +87,8 @@ class AppDatabase extends _$AppDatabase { assert(1 <= from && from <= to && to <= schemaVersion); if (from < 2 && 2 <= to) { - await m.addColumn(accounts, accounts.ackedPushToken); + final schema = Schema2(database: m.database); + await m.addColumn(schema.accounts, schema.accounts.ackedPushToken); } // New migrations go here. } diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart new file mode 100644 index 0000000000..300813c53e --- /dev/null +++ b/lib/model/schema_versions.g.dart @@ -0,0 +1,112 @@ +// dart format width=80 +import 'package:drift/internal/versioned_schema.dart' as i0; +import 'package:drift/drift.dart' as i1; +import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import + +// GENERATED BY drift_dev, DO NOT MODIFY. +final class Schema2 extends i0.VersionedSchema { + Schema2({required super.database}) : super(version: 2); + @override + late final List entities = [ + accounts, + ]; + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null); +} + +class Shape0 extends i0.VersionedTable { + Shape0({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get realmUrl => + columnsByName['realm_url']! as i1.GeneratedColumn; + i1.GeneratedColumn get userId => + columnsByName['user_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get email => + columnsByName['email']! as i1.GeneratedColumn; + i1.GeneratedColumn get apiKey => + columnsByName['api_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get zulipVersion => + columnsByName['zulip_version']! as i1.GeneratedColumn; + i1.GeneratedColumn get zulipMergeBase => + columnsByName['zulip_merge_base']! as i1.GeneratedColumn; + i1.GeneratedColumn get zulipFeatureLevel => + columnsByName['zulip_feature_level']! as i1.GeneratedColumn; + i1.GeneratedColumn get ackedPushToken => + columnsByName['acked_push_token']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_0(String aliasedName) => + i1.GeneratedColumn('id', aliasedName, false, + hasAutoIncrement: true, + type: i1.DriftSqlType.int, + defaultConstraints: + i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); +i1.GeneratedColumn _column_1(String aliasedName) => + i1.GeneratedColumn('realm_url', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_2(String aliasedName) => + i1.GeneratedColumn('user_id', aliasedName, false, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_3(String aliasedName) => + i1.GeneratedColumn('email', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_4(String aliasedName) => + i1.GeneratedColumn('api_key', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_5(String aliasedName) => + i1.GeneratedColumn('zulip_version', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_6(String aliasedName) => + i1.GeneratedColumn('zulip_merge_base', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_7(String aliasedName) => + i1.GeneratedColumn('zulip_feature_level', aliasedName, false, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_8(String aliasedName) => + i1.GeneratedColumn('acked_push_token', aliasedName, true, + type: i1.DriftSqlType.string); +i0.MigrationStepWithVersion migrationSteps({ + required Future Function(i1.Migrator m, Schema2 schema) from1To2, +}) { + return (currentVersion, database) async { + switch (currentVersion) { + case 1: + final schema = Schema2(database: database); + final migrator = i1.Migrator(database, schema); + await from1To2(migrator, schema); + return 2; + default: + throw ArgumentError.value('Unknown migration from $currentVersion'); + } + }; +} + +i1.OnUpgrade stepByStep({ + required Future Function(i1.Migrator m, Schema2 schema) from1To2, +}) => + i0.VersionedSchema.stepByStepHelper( + step: migrationSteps( + from1To2: from1To2, + )); diff --git a/tools/check b/tools/check index fefcd514ed..68b7eb0782 100755 --- a/tools/check +++ b/tools/check @@ -378,13 +378,15 @@ run_l10n() { run_drift() { local schema_dir=test/model/schemas/ + local migration_helper_path=lib/model/schema_versions.g.dart + local outputs=( "${schema_dir}" "${migration_helper_path}" ) # Omitted from this check: # pubspec.{yaml,lock} tools/check - files_check lib/model/database{,.g}.dart "${schema_dir}" \ + files_check lib/model/database{,.g}.dart "${outputs[@]}" \ || return 0 - check_no_uncommitted_or_untracked "${schema_dir}" \ + check_no_uncommitted_or_untracked "${outputs[@]}" \ || return dart run drift_dev schema dump \ @@ -393,8 +395,11 @@ run_drift() { dart run drift_dev schema generate --data-classes --companions \ "${schema_dir}" "${schema_dir}" \ || return + dart run drift_dev schema steps \ + "${schema_dir}" "${migration_helper_path}" \ + || return - check_no_changes "schema updates" "${schema_dir}" + check_no_changes "schema or migration-helper updates" "${outputs[@]}" } filter_flutter_pub_run_output() { From 5fb8158999469674daf5b0980fd3847ca2de6fe8 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 19 Feb 2025 17:45:28 -0500 Subject: [PATCH 094/110] db [nfc]: Use step-by-step migration helper It is a thin wrapper that does what we are already doing in our migration code. While not required, an advantage of this is that it causes compilation errors for missing migration steps. --- lib/model/database.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/model/database.dart b/lib/model/database.dart index 2c48badc96..b8a0f780fe 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -86,13 +86,13 @@ class AppDatabase extends _$AppDatabase { } assert(1 <= from && from <= to && to <= schemaVersion); - if (from < 2 && 2 <= to) { - final schema = Schema2(database: m.database); - await m.addColumn(schema.accounts, schema.accounts.ackedPushToken); - } - // New migrations go here. - } - ); + await m.runMigrationSteps(from: from, to: to, + steps: migrationSteps( + from1To2: (m, schema) async { + await m.addColumn(schema.accounts, schema.accounts.ackedPushToken); + }, + )); + }); } Future createAccount(AccountsCompanion values) async { From 601936da7fd147d040063168187984a7e87dc72b Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 3 Jan 2025 09:54:19 +0800 Subject: [PATCH 095/110] db: Drop all tables on downgrade We previously missed tables that are not known to the schema. This becomes an issue if a new table is added at a newer schema level. When we go back to an earlier schema, that new table remains in the database, so subsequent attempts to upgrade to the later schema level that adds the table will fail, because it already exists. The test for this relies on some undocumented drift internals to modify the schema version it sees when running the migration. References: https://github.com/simolus3/drift/blob/18cede15/drift/lib/src/runtime/executor/helpers/engines.dart#L459-L495 https://github.com/simolus3/drift/blob/18cede15/drift/lib/src/sqlite3/database.dart#L198-L211 https://github.com/simolus3/sqlite3.dart/blob/4de46afd/sqlite3/lib/src/implementation/database.dart#L69-L85 Fixes: #1172 Signed-off-by: Zixuan James Li --- lib/model/database.dart | 51 ++++++++++++++++++++++++++++++----- test/model/database_test.dart | 23 ++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/lib/model/database.dart b/lib/model/database.dart index b8a0f780fe..f9000f5cf5 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -1,7 +1,9 @@ import 'package:drift/drift.dart'; +import 'package:drift/internal/versioned_schema.dart'; import 'package:drift/remote.dart'; import 'package:sqlite3/common.dart'; +import '../log.dart'; import 'schema_versions.g.dart'; part 'database.g.dart'; @@ -49,6 +51,19 @@ class UriConverter extends TypeConverter { @override Uri fromSql(String fromDb) => Uri.parse(fromDb); } +// TODO(drift): generate this +VersionedSchema _getSchema({ + required DatabaseConnectionUser database, + required int schemaVersion, +}) { + switch (schemaVersion) { + case 2: + return Schema2(database: database); + default: + throw Exception('unknown schema version: $schemaVersion'); + } +} + @DriftDatabase(tables: [Accounts]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e); @@ -60,11 +75,37 @@ class AppDatabase extends _$AppDatabase { // and generate database code with build_runner. // See ../../README.md#generated-files for more // information on using the build_runner. + // * Update [_getSchema] to handle the new schemaVersion. // * Write a migration in `onUpgrade` below. // * Write tests. @override int get schemaVersion => 2; // See note. + Future _dropAndCreateAll(Migrator m, { + required int schemaVersion, + }) async { + await m.database.transaction(() async { + final query = m.database.customSelect( + "SELECT name FROM sqlite_master WHERE type='table'"); + for (final row in await query.get()) { + final data = row.data; + final tableName = data['name'] as String; + // Skip sqlite-internal tables. See for comparison: + // https://www.sqlite.org/fileformat2.html#intschema + // https://github.com/simolus3/drift/blob/0901c984a/drift_dev/lib/src/services/schema/verifier_common.dart#L9-L22 + if (tableName.startsWith('sqlite_')) continue; + // No need to worry about SQL injection; this table name + // was already a table name in the database, not something + // that should be affected by user data. + await m.database.customStatement('DROP TABLE $tableName'); + } + final schema = _getSchema(database: m.database, schemaVersion: schemaVersion); + for (final entity in schema.entities) { + await m.create(entity); + } + }); + } + @override MigrationStrategy get migration { return MigrationStrategy( @@ -73,15 +114,11 @@ class AppDatabase extends _$AppDatabase { }, onUpgrade: (Migrator m, int from, int to) async { if (from > to) { - // TODO(log): log schema downgrade as an error // This should only ever happen in dev. As a dev convenience, // drop everything from the database and start over. - for (final entity in allSchemaEntities) { - // This will miss any entire tables (or indexes, etc.) that - // don't exist at this version. For a dev-only feature, that's OK. - await m.drop(entity); - } - await m.createAll(); + // TODO(log): log schema downgrade as an error + assert(debugLog('Downgrading schema from v$from to v$to.')); + await _dropAndCreateAll(m, schemaVersion: to); return; } assert(1 <= from && from <= to && to <= schemaVersion); diff --git a/test/model/database_test.dart b/test/model/database_test.dart index 8b177f9108..0f8b21297c 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -98,6 +98,29 @@ void main() { verifier = SchemaVerifier(GeneratedHelper()); }); + test('downgrading', () async { + final schema = await verifier.schemaAt(2); + + // This simulates the scenario during development when running the app + // with a future schema version that has additional tables and columns. + final before = AppDatabase(schema.newConnection()); + await before.customStatement('CREATE TABLE test_extra (num int)'); + await before.customStatement('ALTER TABLE accounts ADD extra_column int'); + await check(verifier.migrateAndValidate( + before, 2, validateDropped: true)).throws(); + // Override the schema version by modifying the underlying value + // drift internally keeps track of in the database. + // TODO(drift): Expose a better interface for testing this. + await before.customStatement('PRAGMA user_version = 999;'); + await before.close(); + + // Simulate starting up the app, with an older schema version that + // does not have the extra tables and columns. + final after = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(after, 2, validateDropped: true); + await after.close(); + }); + group('migrate without data', () { const versions = GeneratedHelper.versions; final latestVersion = versions.last; From 26073e8ac8b6268b9749805358fcad7d3806e898 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 20 Feb 2025 13:33:35 -0800 Subject: [PATCH 096/110] compose [nfc]: Simplify content-input state, deduplicating topic text Ever since 5827512af made the text input's controller start memoizing `textNormalized` as part of its own state, there's nothing to gain by keeping a copy of it on the widget state too. --- lib/widgets/compose_box.dart | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 2b1756e4fe..6dcb1ac5fe 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -544,18 +544,15 @@ class _StreamContentInput extends StatefulWidget { } class _StreamContentInputState extends State<_StreamContentInput> { - late String _topicTextNormalized; - void _topicChanged() { setState(() { - _topicTextNormalized = widget.controller.topic.textNormalized; + // The relevant state lives on widget.controller.topic itself. }); } @override void initState() { super.initState(); - _topicTextNormalized = widget.controller.topic.textNormalized; widget.controller.topic.addListener(_topicChanged); } @@ -582,9 +579,11 @@ class _StreamContentInputState extends State<_StreamContentInput> { ?? zulipLocalizations.unknownChannelName; return _ContentInput( narrow: widget.narrow, - destination: TopicNarrow(widget.narrow.streamId, TopicName(_topicTextNormalized)), + destination: TopicNarrow(widget.narrow.streamId, + TopicName(widget.controller.topic.textNormalized)), controller: widget.controller, - hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); + hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, + widget.controller.topic.textNormalized)); } } From 7ea7424e46c6428844e9e50fedea573150c0531f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Oct 2024 17:26:47 -0700 Subject: [PATCH 097/110] api [nfc]: Rename resolveDmElements to resolveApiNarrowForServer And refactor the implementation to prepare for a new [ApiNarrow] feature. We're about to add ApiNarrowWith, which new in Zulip Server 9. This function seems like a good place to resolve ApiNarrows into the expected form for servers before 9 (without "with") and 9 or later (with "with"). First we have to make its name and dartdoc more generic, as done here. --- lib/api/model/narrow.dart | 24 +++++++++++++++++------- lib/api/route/messages.dart | 4 ++-- lib/model/internal_link.dart | 2 +- test/api/route/messages_test.dart | 6 +++--- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart index 8082ac64ff..d5c0a6c9ae 100644 --- a/lib/api/model/narrow.dart +++ b/lib/api/model/narrow.dart @@ -6,14 +6,24 @@ part 'narrow.g.dart'; typedef ApiNarrow = List; -/// Resolve any [ApiNarrowDm] elements appropriately. +/// Adapt the given narrow to be sent to the given Zulip server version. /// -/// This encapsulates a server-feature check. -ApiNarrow resolveDmElements(ApiNarrow narrow, int zulipFeatureLevel) { - if (!narrow.any((element) => element is ApiNarrowDm)) { - return narrow; - } +/// Any elements that take a different name on old vs. new servers +/// will be resolved to the specific name to use. +/// Any elements that are unknown to old servers and can +/// reasonably be omitted will be omitted. +ApiNarrow resolveApiNarrowForServer(ApiNarrow narrow, int zulipFeatureLevel) { final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7) + + bool hasDmElement = false; + for (final element in narrow) { + switch (element) { + case ApiNarrowDm(): hasDmElement = true; + default: + } + } + if (!hasDmElement) return narrow; + return narrow.map((element) => switch (element) { ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm), _ => element, @@ -102,7 +112,7 @@ class ApiNarrowTopic extends ApiNarrowElement { /// and more generally its [operator] getter must not be called. /// Instead, call [resolve] and use the object it returns. /// -/// If part of [ApiNarrow] use [resolveDmElements]. +/// If part of [ApiNarrow] use [resolveApiNarrowForServer]. class ApiNarrowDm extends ApiNarrowElement { @override String get operator { assert(false, diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index be6728c790..5af312ce45 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -91,7 +91,7 @@ Future getMessages(ApiConnection connection, { // bool? useFirstUnreadAnchor // omitted because deprecated }) { return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', { - 'narrow': resolveDmElements(narrow, connection.zulipFeatureLevel!), + 'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!), 'anchor': RawParameter(anchor.toJson()), if (includeAnchor != null) 'include_anchor': includeAnchor, 'num_before': numBefore, @@ -400,7 +400,7 @@ Future updateMessageFlagsForNarrow(ApiConnect if (includeAnchor != null) 'include_anchor': includeAnchor, 'num_before': numBefore, 'num_after': numAfter, - 'narrow': resolveDmElements(narrow, connection.zulipFeatureLevel!), + 'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!), 'op': RawParameter(op.toJson()), 'flag': RawParameter(flag.toJson()), }); diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index db11115cf3..fe4545ca5e 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -59,7 +59,7 @@ String? decodeHashComponent(String str) { // you do so by passing the `anchor` param. Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { // TODO(server-7) - final apiNarrow = resolveDmElements( + final apiNarrow = resolveApiNarrowForServer( narrow.apiEncode(), store.connection.zulipFeatureLevel!); final fragment = StringBuffer('narrow'); for (ApiNarrowElement element in apiNarrow) { diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 0d969f0531..1143f38a1f 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -172,7 +172,7 @@ void main() { test('Narrow.toJson', () { return FakeApiConnection.with_((connection) async { void checkNarrow(ApiNarrow narrow, String expected) { - narrow = resolveDmElements(narrow, connection.zulipFeatureLevel!); + narrow = resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!); check(jsonEncode(narrow)).equals(expected); } @@ -259,7 +259,7 @@ void main() { }); }); - test('narrow uses resolveDmElements to encode', () { + test('narrow uses resolveApiNarrowForServer to encode', () { return FakeApiConnection.with_(zulipFeatureLevel: 176, (connection) async { connection.prepare(json: fakeResult.toJson()); await checkGetMessages(connection, @@ -707,7 +707,7 @@ void main() { }); }); - test('narrow uses resolveDmElements to encode', () { + test('narrow uses resolveApiNarrowForServer to encode', () { return FakeApiConnection.with_(zulipFeatureLevel: 176, (connection) async { connection.prepare(json: mkResult(foundOldest: true).toJson()); await checkUpdateMessageFlagsForNarrow(connection, From 48da972bfe5365d9ae131140c97bd3b79e1be919 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Oct 2024 18:14:57 -0700 Subject: [PATCH 098/110] msglist test [nfc]: Make groups for fetchInitial and fetchOlder tests --- test/model/message_list_test.dart | 384 +++++++++++++++--------------- 1 file changed, 194 insertions(+), 190 deletions(-) diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 2fa97fd5e3..c5244c1619 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -95,221 +95,225 @@ void main() { }); } - test('fetchInitial', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - connection.prepare(json: newestResult( - foundOldest: false, - messages: List.generate(kMessageListFetchBatchSize, - (i) => eg.streamMessage()), - ).toJson()); - final fetchFuture = model.fetchInitial(); - check(model).fetched.isFalse(); + group('fetchInitial', () { + test('smoke', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow); + connection.prepare(json: newestResult( + foundOldest: false, + messages: List.generate(kMessageListFetchBatchSize, + (i) => eg.streamMessage()), + ).toJson()); + final fetchFuture = model.fetchInitial(); + check(model).fetched.isFalse(); - checkNotNotified(); - await fetchFuture; - checkNotifiedOnce(); - check(model) - ..messages.length.equals(kMessageListFetchBatchSize) - ..haveOldest.isFalse(); - checkLastRequest( - narrow: narrow.apiEncode(), - anchor: 'newest', - numBefore: kMessageListFetchBatchSize, - numAfter: 0, - ); - }); + checkNotNotified(); + await fetchFuture; + checkNotifiedOnce(); + check(model) + ..messages.length.equals(kMessageListFetchBatchSize) + ..haveOldest.isFalse(); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: 'newest', + numBefore: kMessageListFetchBatchSize, + numAfter: 0, + ); + }); - test('fetchInitial, short history', () async { - await prepare(); - connection.prepare(json: newestResult( - foundOldest: true, - messages: List.generate(30, (i) => eg.streamMessage()), - ).toJson()); - await model.fetchInitial(); - checkNotifiedOnce(); - check(model) - ..messages.length.equals(30) - ..haveOldest.isTrue(); - }); + test('short history', () async { + await prepare(); + connection.prepare(json: newestResult( + foundOldest: true, + messages: List.generate(30, (i) => eg.streamMessage()), + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(30) + ..haveOldest.isTrue(); + }); - test('fetchInitial, no messages found', () async { - await prepare(); - connection.prepare(json: newestResult( - foundOldest: true, - messages: [], - ).toJson()); - await model.fetchInitial(); - checkNotifiedOnce(); - check(model) - ..fetched.isTrue() - ..messages.isEmpty() - ..haveOldest.isTrue(); - }); + test('no messages found', () async { + await prepare(); + connection.prepare(json: newestResult( + foundOldest: true, + messages: [], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..messages.isEmpty() + ..haveOldest.isTrue(); + }); - // TODO(#824): move this test - test('fetchInitial, recent senders track all the messages', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - final messages = [ - eg.streamMessage(), - // Not subscribed to the stream with id 10. - eg.streamMessage(stream: eg.stream(streamId: 10)), - ]; - connection.prepare(json: newestResult( - foundOldest: false, - messages: messages, - ).toJson()); - await model.fetchInitial(); + // TODO(#824): move this test + test('recent senders track all the messages', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow); + final messages = [ + eg.streamMessage(), + // Not subscribed to the stream with id 10. + eg.streamMessage(stream: eg.stream(streamId: 10)), + ]; + connection.prepare(json: newestResult( + foundOldest: false, + messages: messages, + ).toJson()); + await model.fetchInitial(); - check(model).messages.length.equals(1); - recent_senders_test.checkMatchesMessages(store.recentSenders, messages); + check(model).messages.length.equals(1); + recent_senders_test.checkMatchesMessages(store.recentSenders, messages); + }); }); - test('fetchOlder', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - await prepareMessages(foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + group('fetchOlder', () { + test('smoke', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow); + await prepareMessages(foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), - ).toJson()); - final fetchFuture = model.fetchOlder(); - checkNotifiedOnce(); - check(model).fetchingOlder.isTrue(); + connection.prepare(json: olderResult( + anchor: 1000, foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), + ).toJson()); + final fetchFuture = model.fetchOlder(); + checkNotifiedOnce(); + check(model).fetchingOlder.isTrue(); - await fetchFuture; - checkNotifiedOnce(); - check(model) - ..fetchingOlder.isFalse() - ..messages.length.equals(200); - checkLastRequest( - narrow: narrow.apiEncode(), - anchor: '1000', - includeAnchor: false, - numBefore: kMessageListFetchBatchSize, - numAfter: 0, - ); - }); + await fetchFuture; + checkNotifiedOnce(); + check(model) + ..fetchingOlder.isFalse() + ..messages.length.equals(200); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: '1000', + includeAnchor: false, + numBefore: kMessageListFetchBatchSize, + numAfter: 0, + ); + }); - test('fetchOlder nop when already fetching', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - await prepareMessages(foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + test('nop when already fetching', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow); + await prepareMessages(foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), - ).toJson()); - final fetchFuture = model.fetchOlder(); - checkNotifiedOnce(); - check(model).fetchingOlder.isTrue(); + connection.prepare(json: olderResult( + anchor: 1000, foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), + ).toJson()); + final fetchFuture = model.fetchOlder(); + checkNotifiedOnce(); + check(model).fetchingOlder.isTrue(); - // Don't prepare another response. - final fetchFuture2 = model.fetchOlder(); - checkNotNotified(); - check(model).fetchingOlder.isTrue(); + // Don't prepare another response. + final fetchFuture2 = model.fetchOlder(); + checkNotNotified(); + check(model).fetchingOlder.isTrue(); - await fetchFuture; - await fetchFuture2; - // We must not have made another request, because we didn't - // prepare another response and didn't get an exception. - checkNotifiedOnce(); - check(model) - ..fetchingOlder.isFalse() - ..messages.length.equals(200); - }); + await fetchFuture; + await fetchFuture2; + // We must not have made another request, because we didn't + // prepare another response and didn't get an exception. + checkNotifiedOnce(); + check(model) + ..fetchingOlder.isFalse() + ..messages.length.equals(200); + }); - test('fetchOlder nop when already haveOldest true', () async { - await prepare(narrow: const CombinedFeedNarrow()); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage())); - check(model) - ..haveOldest.isTrue() - ..messages.length.equals(30); + test('nop when already haveOldest true', () async { + await prepare(narrow: const CombinedFeedNarrow()); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage())); + check(model) + ..haveOldest.isTrue() + ..messages.length.equals(30); - await model.fetchOlder(); - // We must not have made a request, because we didn't - // prepare a response and didn't get an exception. - checkNotNotified(); - check(model) - ..haveOldest.isTrue() - ..messages.length.equals(30); - }); + await model.fetchOlder(); + // We must not have made a request, because we didn't + // prepare a response and didn't get an exception. + checkNotNotified(); + check(model) + ..haveOldest.isTrue() + ..messages.length.equals(30); + }); - test('fetchOlder nop during backoff', () => awaitFakeAsync((async) async { - final olderMessages = List.generate(5, (i) => eg.streamMessage()); - final initialMessages = List.generate(5, (i) => eg.streamMessage()); - await prepare(narrow: const CombinedFeedNarrow()); - await prepareMessages(foundOldest: false, messages: initialMessages); - check(connection.takeRequests()).single; + test('nop during backoff', () => awaitFakeAsync((async) async { + final olderMessages = List.generate(5, (i) => eg.streamMessage()); + final initialMessages = List.generate(5, (i) => eg.streamMessage()); + await prepare(narrow: const CombinedFeedNarrow()); + await prepareMessages(foundOldest: false, messages: initialMessages); + check(connection.takeRequests()).single; - connection.prepare(apiException: eg.apiBadRequest()); - check(async.pendingTimers).isEmpty(); - await check(model.fetchOlder()).throws(); - checkNotified(count: 2); - check(model).fetchOlderCoolingDown.isTrue(); - check(connection.takeRequests()).single; + connection.prepare(apiException: eg.apiBadRequest()); + check(async.pendingTimers).isEmpty(); + await check(model.fetchOlder()).throws(); + checkNotified(count: 2); + check(model).fetchOlderCoolingDown.isTrue(); + check(connection.takeRequests()).single; - await model.fetchOlder(); - checkNotNotified(); - check(model).fetchOlderCoolingDown.isTrue(); - check(model).fetchingOlder.isFalse(); - check(connection.lastRequest).isNull(); + await model.fetchOlder(); + checkNotNotified(); + check(model).fetchOlderCoolingDown.isTrue(); + check(model).fetchingOlder.isFalse(); + check(connection.lastRequest).isNull(); - // Wait long enough that a first backoff is sure to finish. - async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isFalse(); - checkNotifiedOnce(); - check(connection.lastRequest).isNull(); + // Wait long enough that a first backoff is sure to finish. + async.elapse(const Duration(seconds: 1)); + check(model).fetchOlderCoolingDown.isFalse(); + checkNotifiedOnce(); + check(connection.lastRequest).isNull(); - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, messages: olderMessages).toJson()); - await model.fetchOlder(); - checkNotified(count: 2); - check(connection.takeRequests()).single; - })); + connection.prepare(json: olderResult( + anchor: 1000, foundOldest: false, messages: olderMessages).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + check(connection.takeRequests()).single; + })); - test('fetchOlder handles servers not understanding includeAnchor', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - await prepareMessages(foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + test('handles servers not understanding includeAnchor', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow); + await prepareMessages(foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); - // The old behavior is to include the anchor message regardless of includeAnchor. - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, foundAnchor: true, - messages: List.generate(101, (i) => eg.streamMessage(id: 900 + i)), - ).toJson()); - await model.fetchOlder(); - checkNotified(count: 2); - check(model) - ..fetchingOlder.isFalse() - ..messages.length.equals(200); - }); + // The old behavior is to include the anchor message regardless of includeAnchor. + connection.prepare(json: olderResult( + anchor: 1000, foundOldest: false, foundAnchor: true, + messages: List.generate(101, (i) => eg.streamMessage(id: 900 + i)), + ).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + check(model) + ..fetchingOlder.isFalse() + ..messages.length.equals(200); + }); - // TODO(#824): move this test - test('fetchOlder, recent senders track all the messages', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); - await prepareMessages(foundOldest: false, messages: initialMessages); + // TODO(#824): move this test + test('recent senders track all the messages', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow); + final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); + await prepareMessages(foundOldest: false, messages: initialMessages); - final oldMessages = List.generate(10, (i) => eg.streamMessage(id: 89 + i)) - // Not subscribed to the stream with id 10. - ..add(eg.streamMessage(id: 99, stream: eg.stream(streamId: 10))); - connection.prepare(json: olderResult( - anchor: 100, foundOldest: false, - messages: oldMessages, - ).toJson()); - await model.fetchOlder(); + final oldMessages = List.generate(10, (i) => eg.streamMessage(id: 89 + i)) + // Not subscribed to the stream with id 10. + ..add(eg.streamMessage(id: 99, stream: eg.stream(streamId: 10))); + connection.prepare(json: olderResult( + anchor: 100, foundOldest: false, + messages: oldMessages, + ).toJson()); + await model.fetchOlder(); - check(model).messages.length.equals(20); - recent_senders_test.checkMatchesMessages(store.recentSenders, - [...initialMessages, ...oldMessages]); + check(model).messages.length.equals(20); + recent_senders_test.checkMatchesMessages(store.recentSenders, + [...initialMessages, ...oldMessages]); + }); }); test('MessageEvent', () async { From 1b590364c984274e1ac1f26a80c00ba10936eeca Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 30 Oct 2024 11:38:33 -0700 Subject: [PATCH 099/110] msglist test [nfc]: Refactor fetchInitial smoke, preparing for more narrows --- test/model/message_list_test.dart | 51 ++++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index c5244c1619..28731bdfe1 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -96,29 +96,36 @@ void main() { } group('fetchInitial', () { - test('smoke', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); - connection.prepare(json: newestResult( - foundOldest: false, - messages: List.generate(kMessageListFetchBatchSize, - (i) => eg.streamMessage()), - ).toJson()); - final fetchFuture = model.fetchInitial(); - check(model).fetched.isFalse(); + group('smoke', () { + Future smoke( + Narrow narrow, + Message Function(int i) generateMessages, + ) async { + await prepare(narrow: narrow); + connection.prepare(json: newestResult( + foundOldest: false, + messages: List.generate(kMessageListFetchBatchSize, generateMessages), + ).toJson()); + final fetchFuture = model.fetchInitial(); + check(model).fetched.isFalse(); - checkNotNotified(); - await fetchFuture; - checkNotifiedOnce(); - check(model) - ..messages.length.equals(kMessageListFetchBatchSize) - ..haveOldest.isFalse(); - checkLastRequest( - narrow: narrow.apiEncode(), - anchor: 'newest', - numBefore: kMessageListFetchBatchSize, - numAfter: 0, - ); + checkNotNotified(); + await fetchFuture; + checkNotifiedOnce(); + check(model) + ..messages.length.equals(kMessageListFetchBatchSize) + ..haveOldest.isFalse(); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: 'newest', + numBefore: kMessageListFetchBatchSize, + numAfter: 0, + ); + } + + test('CombinedFeedNarrow', () async { + await smoke(const CombinedFeedNarrow(), (i) => eg.streamMessage()); + }); }); test('short history', () async { From c6556310f6fe6fc595faf019e14b331f13d341ea Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Oct 2024 18:37:18 -0700 Subject: [PATCH 100/110] msglist test: Add fetchInitial smoke test that uses a topic narrow --- test/model/message_list_test.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 28731bdfe1..f7047de711 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -96,6 +96,9 @@ void main() { } group('fetchInitial', () { + final someChannel = eg.stream(); + const someTopic = 'some topic'; + group('smoke', () { Future smoke( Narrow narrow, @@ -126,6 +129,11 @@ void main() { test('CombinedFeedNarrow', () async { await smoke(const CombinedFeedNarrow(), (i) => eg.streamMessage()); }); + + test('TopicNarrow', () async { + await smoke(TopicNarrow(someChannel.streamId, eg.t(someTopic)), + (i) => eg.streamMessage(stream: someChannel, topic: someTopic)); + }); }); test('short history', () async { From 327ae54c428946b50b734bff3daaa723699618b3 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 14 Feb 2025 12:31:29 -0800 Subject: [PATCH 101/110] msglist test [nfc]: Remove indentation on an empty line --- test/widgets/message_list_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 1aa1af81c5..75d79dc050 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1100,7 +1100,7 @@ void main() { .initNarrow.equals(DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId)); await tester.pumpAndSettle(); }); - + testWidgets('does not navigate on tapping recipient header in DmNarrow', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() From a458ac24d975b7b96715bf6a6a13de29005d1f44 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 30 Oct 2024 13:03:53 -0700 Subject: [PATCH 102/110] msglist test [nfc]: Add feature-level param to setupMessageListPage --- test/widgets/message_list_test.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 75d79dc050..544585d1d0 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -56,6 +56,7 @@ void main() { List? users, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, + int? zulipFeatureLevel, List navObservers = const [], bool skipAssertAccountExists = false, }) async { @@ -63,9 +64,12 @@ void main() { addTearDown(TypingNotifier.debugReset); addTearDown(testBinding.reset); streams ??= subscriptions ??= [eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId))]; - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + zulipFeatureLevel ??= eg.recentZulipFeatureLevel; + final selfAccount = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel, streams: streams, subscriptions: subscriptions, unreadMsgs: unreadMsgs)); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(selfAccount.id); connection = store.connection as FakeApiConnection; // prepare message list data @@ -78,7 +82,7 @@ void main() { connection.prepare(json: eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, skipAssertAccountExists: skipAssertAccountExists, navigatorObservers: navObservers, child: MessageListPage(initNarrow: narrow))); From be71501676b40db8340b545866419474e5295a44 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 12 Feb 2025 17:56:00 -0800 Subject: [PATCH 103/110] msglist test: Add and use helper checkAppBarChannelTopic We'll use this for some new tests, coming up. Also use it now for one test that's only been checking the topic in the app bar. That test now checks the channel name too, as it was before b1767b231, which dropped that specificity when we split the app bar's channel name and topic onto two rows. --- test/widgets/message_list_test.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 544585d1d0..c463316e28 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -91,6 +91,15 @@ void main() { await tester.pumpAndSettle(); } + void checkAppBarChannelTopic(String channelName, String topic) { + final appBarFinder = find.byType(MessageListAppBarTitle); + check(appBarFinder).findsOne(); + check(find.descendant(of: appBarFinder, matching: find.text(channelName))) + .findsOne(); + check(find.descendant(of: appBarFinder, matching: find.text(topic))) + .findsOne(); + } + ScrollController? findMessageListScrollController(WidgetTester tester) { final scrollView = tester.widget(find.byType(CustomScrollView)); return scrollView.controller; @@ -783,10 +792,7 @@ void main() { of: find.byType(RecipientHeader), matching: find.text('new topic')).evaluate() ).length.equals(1); - check(find.descendant( - of: find.byType(MessageListAppBarTitle), - matching: find.text('new topic')).evaluate() - ).length.equals(1); + checkAppBarChannelTopic(channel.name, 'new topic'); }); }); From 5f3a68280c7b8ccf06b0ee8b803ce577a5d4a2e2 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Feb 2025 18:24:26 -0800 Subject: [PATCH 104/110] api test [nfc]: Separate some checks on "special" narrows from other checks And fix the name of the test. --- test/api/route/messages_test.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 1143f38a1f..7af3234a3a 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -169,13 +169,20 @@ void main() { }); }); - test('Narrow.toJson', () { + test('ApiNarrow.toJson', () { return FakeApiConnection.with_((connection) async { void checkNarrow(ApiNarrow narrow, String expected) { narrow = resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!); check(jsonEncode(narrow)).equals(expected); } + checkNarrow(const MentionsNarrow().apiEncode(), jsonEncode([ + {'operator': 'is', 'operand': 'mentioned'}, + ])); + checkNarrow(const StarredMessagesNarrow().apiEncode(), jsonEncode([ + {'operator': 'is', 'operand': 'starred'}, + ])); + checkNarrow(const CombinedFeedNarrow().apiEncode(), jsonEncode([])); checkNarrow(const ChannelNarrow(12).apiEncode(), jsonEncode([ {'operator': 'stream', 'operand': 12}, @@ -184,12 +191,6 @@ void main() { {'operator': 'stream', 'operand': 12}, {'operator': 'topic', 'operand': 'stuff'}, ])); - checkNarrow(const MentionsNarrow().apiEncode(), jsonEncode([ - {'operator': 'is', 'operand': 'mentioned'}, - ])); - checkNarrow(const StarredMessagesNarrow().apiEncode(), jsonEncode([ - {'operator': 'is', 'operand': 'starred'}, - ])); checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ {'operator': 'dm', 'operand': [123, 234]}, From c8494a8ef7aa937df9f29bce94eb142254cba04d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 18 Oct 2024 18:00:03 -0700 Subject: [PATCH 105/110] msglist: Follow /with/ links through message moves Fixes: #1028 --- lib/api/model/narrow.dart | 41 ++++++++++++--- lib/model/internal_link.dart | 19 +++++-- lib/model/message_list.dart | 36 +++++++++++++ lib/model/narrow.dart | 24 +++++++-- lib/widgets/message_list.dart | 7 ++- test/api/model/narrow_test.dart | 4 ++ test/api/route/messages_test.dart | 28 +++++++++++ test/example_data.dart | 4 +- test/model/internal_link_test.dart | 13 ++--- test/model/message_list_test.dart | 29 +++++++++++ test/model/narrow_checks.dart | 1 + test/widgets/message_list_test.dart | 78 +++++++++++++++++++++++++++++ 12 files changed, 259 insertions(+), 25 deletions(-) create mode 100644 test/api/model/narrow_test.dart diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart index d5c0a6c9ae..641e78de49 100644 --- a/lib/api/model/narrow.dart +++ b/lib/api/model/narrow.dart @@ -14,20 +14,33 @@ typedef ApiNarrow = List; /// reasonably be omitted will be omitted. ApiNarrow resolveApiNarrowForServer(ApiNarrow narrow, int zulipFeatureLevel) { final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7) + final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9) bool hasDmElement = false; + bool hasWithElement = false; for (final element in narrow) { switch (element) { - case ApiNarrowDm(): hasDmElement = true; + case ApiNarrowDm(): hasDmElement = true; + case ApiNarrowWith(): hasWithElement = true; default: } } - if (!hasDmElement) return narrow; + if (!(hasDmElement || (hasWithElement && !supportsOperatorWith))) { + return narrow; + } - return narrow.map((element) => switch (element) { - ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm), - _ => element, - }).toList(); + final result = []; + for (final element in narrow) { + switch (element) { + case ApiNarrowDm(): + result.add(element.resolve(legacy: !supportsOperatorDm)); + case ApiNarrowWith() when !supportsOperatorWith: + break; // drop unsupported element + default: + result.add(element); + } + } + return result; } /// An element in the list representing a narrow in the Zulip API. @@ -160,6 +173,22 @@ class ApiNarrowPmWith extends ApiNarrowDm { ApiNarrowPmWith._(super.operand, {super.negated}); } +/// An [ApiNarrowElement] with the 'with' operator. +/// +/// If part of [ApiNarrow] use [resolveApiNarrowForServer]. +class ApiNarrowWith extends ApiNarrowElement { + @override String get operator => 'with'; + + @override final int operand; + + ApiNarrowWith(this.operand, {super.negated}); + + factory ApiNarrowWith.fromJson(Map json) => ApiNarrowWith( + json['operand'] as int, + negated: json['negated'] as bool? ?? false, + ); +} + class ApiNarrowIs extends ApiNarrowElement { @override String get operator => 'is'; diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index fe4545ca5e..1290259033 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -86,6 +86,8 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { fragment.write('${element.operand.join(',')}-$suffix'); case ApiNarrowDm(): assert(false, 'ApiNarrowDm should have been resolved'); + case ApiNarrowWith(): + fragment.write(element.operand.toString()); case ApiNarrowIs(): fragment.write(element.operand.toString()); case ApiNarrowMessageId(): @@ -160,6 +162,7 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { ApiNarrowStream? streamElement; ApiNarrowTopic? topicElement; ApiNarrowDm? dmElement; + ApiNarrowWith? withElement; Set isElementOperands = {}; for (var i = 0; i < segments.length; i += 2) { @@ -188,12 +191,17 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { if (dmIds == null) return null; dmElement = ApiNarrowDm(dmIds, negated: negated); + case _NarrowOperator.with_: + if (withElement != null) return null; + final messageId = int.tryParse(operand, radix: 10); + if (messageId == null) return null; + withElement = ApiNarrowWith(messageId); + case _NarrowOperator.is_: // It is fine to have duplicates of the same [IsOperand]. isElementOperands.add(IsOperand.fromRawString(operand)); case _NarrowOperator.near: // TODO(#82): support for near - case _NarrowOperator.with_: // TODO(#683): support for with continue; case _NarrowOperator.unknown: @@ -202,7 +210,9 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { } if (isElementOperands.isNotEmpty) { - if (streamElement != null || topicElement != null || dmElement != null) return null; + if (streamElement != null || topicElement != null || dmElement != null || withElement != null) { + return null; + } if (isElementOperands.length > 1) return null; switch (isElementOperands.single) { case IsOperand.mentioned: @@ -219,13 +229,14 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { return null; } } else if (dmElement != null) { - if (streamElement != null || topicElement != null) return null; + if (streamElement != null || topicElement != null || withElement != null) return null; return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId); } else if (streamElement != null) { final streamId = streamElement.operand; if (topicElement != null) { - return TopicNarrow(streamId, topicElement.operand); + return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand); } else { + if (withElement != null) return null; return ChannelNarrow(streamId); } } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 670785ac4e..ea120cd72e 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -511,6 +511,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { numAfter: 0, ); if (this.generation > generation) return; + _adjustNarrowForTopicPermalink(result.messages.firstOrNull); store.reconcileMessages(result.messages); store.recentSenders.handleMessages(result.messages); // TODO(#824) for (final message in result.messages) { @@ -524,12 +525,47 @@ class MessageListView with ChangeNotifier, _MessageSequence { notifyListeners(); } + /// Update [narrow] for the result of a "with" narrow (topic permalink) fetch. + /// + /// To avoid an extra round trip, the server handles [ApiNarrowWith] + /// by returning results from the indicated message's current stream/topic + /// (if the user has access), + /// even if that differs from the narrow's stream/topic filters + /// because the message was moved. + /// + /// If such a "redirect" happened, this helper updates the stream and topic + /// in [narrow] to match the message's current conversation. + /// It also removes the "with" component from [narrow] + /// whether or not a redirect happened. + /// + /// See API doc: + /// https://zulip.com/api/construct-narrow#message-ids + void _adjustNarrowForTopicPermalink(Message? someFetchedMessageOrNull) { + final narrow = this.narrow; + if (narrow is! TopicNarrow || narrow.with_ == null) return; + + switch (someFetchedMessageOrNull) { + case null: + // This can't be a redirect; a redirect can't produce an empty result. + // (The server only redirects if the message is accessible to the user, + // and if it is, it'll appear in the result, making it non-empty.) + this.narrow = narrow.sansWith(); + case StreamMessage(): + this.narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull); + case DmMessage(): // TODO(log) + assert(false); + } + } + /// Fetch the next batch of older messages, if applicable. Future fetchOlder() async { if (haveOldest) return; if (fetchingOlder) return; if (fetchOlderCoolingDown) return; assert(fetched); + assert(narrow is! TopicNarrow + // We only intend to send "with" in [fetchInitial]; see there. + || (narrow as TopicNarrow).with_ == null); assert(messages.isNotEmpty); _fetchingOlder = true; _updateEndMarkers(); diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index 9e29808ceb..25b14ff980 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -92,7 +92,7 @@ class ChannelNarrow extends Narrow { } class TopicNarrow extends Narrow implements SendableNarrow { - const TopicNarrow(this.streamId, this.topic); + const TopicNarrow(this.streamId, this.topic, {this.with_}); factory TopicNarrow.ofMessage(StreamMessage message) { return TopicNarrow(message.streamId, message.topic); @@ -100,6 +100,9 @@ class TopicNarrow extends Narrow implements SendableNarrow { final int streamId; final TopicName topic; + final int? with_; + + TopicNarrow sansWith() => TopicNarrow(streamId, topic); @override bool containsMessage(Message message) { @@ -108,22 +111,33 @@ class TopicNarrow extends Narrow implements SendableNarrow { } @override - ApiNarrow apiEncode() => [ApiNarrowStream(streamId), ApiNarrowTopic(topic)]; + ApiNarrow apiEncode() => [ + ApiNarrowStream(streamId), + ApiNarrowTopic(topic), + if (with_ != null) ApiNarrowWith(with_!), + ]; @override StreamDestination get destination => StreamDestination(streamId, topic); @override - String toString() => 'TopicNarrow($streamId, ${topic.displayName})'; + String toString() { + final fields = [ + streamId.toString(), + topic.displayName, + if (with_ != null) 'with: ${with_!}', + ]; + return 'TopicNarrow(${fields.join(', ')})'; + } @override bool operator ==(Object other) { if (other is! TopicNarrow) return false; - return other.streamId == streamId && other.topic == topic; + return other.streamId == streamId && other.topic == topic && other.with_ == with_; } @override - int get hashCode => Object.hash('TopicNarrow', streamId, topic); + int get hashCode => Object.hash('TopicNarrow', streamId, topic, with_); } /// The narrow for a direct-message conversation. diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 48c0d70b59..127bd65c61 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -503,8 +503,11 @@ class _MessageListState extends State with PerAccountStoreAwareStat void _modelChanged() { if (model!.narrow != widget.narrow) { - // A message move event occurred, where propagate mode is - // [PropagateMode.changeAll] or [PropagateMode.changeLater]. + // Either: + // - A message move event occurred, where propagate mode is + // [PropagateMode.changeAll] or [PropagateMode.changeLater]. Or: + // - We fetched a "with" / topic-permalink narrow, and the response + // redirected us to the new location of the operand message ID. widget.onNarrowChanged(model!.narrow); } setState(() { diff --git a/test/api/model/narrow_test.dart b/test/api/model/narrow_test.dart new file mode 100644 index 0000000000..42c991c6e4 --- /dev/null +++ b/test/api/model/narrow_test.dart @@ -0,0 +1,4 @@ +void main() { + // resolveApiNarrowForServer is covered in test/api/route/messages_test.dart, + // in the ApiNarrow.toJson test. +} diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 7af3234a3a..a29a87e24b 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -191,15 +191,43 @@ void main() { {'operator': 'stream', 'operand': 12}, {'operator': 'topic', 'operand': 'stuff'}, ])); + checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + {'operator': 'with', 'operand': 1}, + ])); + checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ + {'operator': 'dm', 'operand': [123, 234]}, + ])); + checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([ + {'operator': 'dm', 'operand': [123, 234]}, + {'operator': 'with', 'operand': 1}, + ])); + connection.zulipFeatureLevel = 270; + checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ {'operator': 'dm', 'operand': [123, 234]}, ])); + checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([ + {'operator': 'dm', 'operand': [123, 234]}, + ])); connection.zulipFeatureLevel = 176; + checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ {'operator': 'pm-with', 'operand': [123, 234]}, ])); + checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([ + {'operator': 'pm-with', 'operand': [123, 234]}, + ])); + connection.zulipFeatureLevel = eg.futureZulipFeatureLevel; }); }); diff --git a/test/example_data.dart b/test/example_data.dart index a758481f52..04d6723b7a 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -332,8 +332,8 @@ Subscription subscription( /// Useful in test code that mentions a lot of topics in a compact format. TopicName t(String apiName) => TopicName(apiName); -TopicNarrow topicNarrow(int channelId, String topicName) { - return TopicNarrow(channelId, TopicName(topicName)); +TopicNarrow topicNarrow(int channelId, String topicName, {int? with_}) { + return TopicNarrow(channelId, TopicName(topicName), with_: with_); } UserTopicItem userTopicItem( diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart index eb91e35855..611cc3ece0 100644 --- a/test/model/internal_link_test.dart +++ b/test/model/internal_link_test.dart @@ -284,13 +284,14 @@ void main() { final testCases = [ ('/#narrow/stream/check/topic/test', eg.topicNarrow(1, 'test')), ('/#narrow/stream/mobile/subject/topic/near/378333', eg.topicNarrow(3, 'topic')), - ('/#narrow/stream/mobile/subject/topic/with/1', eg.topicNarrow(3, 'topic')), + ('/#narrow/stream/mobile/subject/topic/with/1', eg.topicNarrow(3, 'topic', with_: 1)), ('/#narrow/stream/mobile/topic/topic/', eg.topicNarrow(3, 'topic')), ('/#narrow/stream/stream/topic/topic/near/1', eg.topicNarrow(5, 'topic')), - ('/#narrow/stream/stream/topic/topic/with/22', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/topic/topic/with/22', eg.topicNarrow(5, 'topic', with_: 22)), ('/#narrow/stream/stream/subject/topic/near/1', eg.topicNarrow(5, 'topic')), - ('/#narrow/stream/stream/subject/topic/with/333', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic/with/333', eg.topicNarrow(5, 'topic', with_: 333)), ('/#narrow/stream/stream/subject/topic', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic/with/asdf', null), // invalid `with` ]; testExpectedNarrows(testCases, streams: streams); }); @@ -313,7 +314,7 @@ void main() { final testCases = [ ('/#narrow/dm/1,2-group', expectedNarrow), ('/#narrow/dm/1,2-group/near/1', expectedNarrow), - ('/#narrow/dm/1,2-group/with/2', expectedNarrow), + ('/#narrow/dm/1,2-group/with/2', null), ('/#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', null), ('/#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom/with/4', null), ]; @@ -326,7 +327,7 @@ void main() { final testCases = [ ('/#narrow/pm-with/1,2-group', expectedNarrow), ('/#narrow/pm-with/1,2-group/near/1', expectedNarrow), - ('/#narrow/pm-with/1,2-group/with/2', expectedNarrow), + ('/#narrow/pm-with/1,2-group/with/2', null), ('/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', null), ('/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/with/3', null), ]; @@ -342,7 +343,7 @@ void main() { ('/#narrow/is/$operand', narrow), ('/#narrow/is/$operand/is/$operand', narrow), ('/#narrow/is/$operand/near/1', narrow), - ('/#narrow/is/$operand/with/2', narrow), + ('/#narrow/is/$operand/with/2', null), ('/#narrow/channel/7-test-here/is/$operand', null), ('/#narrow/channel/check/topic/test/is/$operand', null), ('/#narrow/topic/test/is/$operand', null), diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index f7047de711..da286f3ef5 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -99,6 +99,9 @@ void main() { final someChannel = eg.stream(); const someTopic = 'some topic'; + final otherChannel = eg.stream(); + const otherTopic = 'other topic'; + group('smoke', () { Future smoke( Narrow narrow, @@ -181,6 +184,32 @@ void main() { check(model).messages.length.equals(1); recent_senders_test.checkMatchesMessages(store.recentSenders, messages); }); + + group('topic permalinks', () { + test('if redirect, we follow it and remove "with" element', () async { + await prepare(narrow: TopicNarrow(someChannel.streamId, eg.t(someTopic), with_: 1)); + connection.prepare(json: newestResult( + foundOldest: false, + messages: [eg.streamMessage(id: 1, stream: otherChannel, topic: otherTopic)], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model).narrow + .equals(TopicNarrow(otherChannel.streamId, eg.t(otherTopic))); + }); + + test('if no redirect, we still remove "with" element', () async { + await prepare(narrow: TopicNarrow(someChannel.streamId, eg.t(someTopic), with_: 1)); + connection.prepare(json: newestResult( + foundOldest: false, + messages: [eg.streamMessage(id: 1, stream: someChannel, topic: someTopic)], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model).narrow + .equals(TopicNarrow(someChannel.streamId, eg.t(someTopic))); + }); + }); }); group('fetchOlder', () { diff --git a/test/model/narrow_checks.dart b/test/model/narrow_checks.dart index ce65de854d..df141654dd 100644 --- a/test/model/narrow_checks.dart +++ b/test/model/narrow_checks.dart @@ -16,4 +16,5 @@ extension DmNarrowChecks on Subject { extension TopicNarrowChecks on Subject { Subject get streamId => has((x) => x.streamId, 'streamId'); Subject get topic => has((x) => x.topic, 'topic'); + Subject get with_ => has((x) => x.with_, 'with_'); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index c463316e28..b7384824fd 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -14,6 +14,7 @@ import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; @@ -59,6 +60,7 @@ void main() { int? zulipFeatureLevel, List navObservers = const [], bool skipAssertAccountExists = false, + bool skipPumpAndSettle = false, }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); @@ -87,6 +89,7 @@ void main() { navigatorObservers: navObservers, child: MessageListPage(initNarrow: narrow))); + if (skipPumpAndSettle) return; // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); } @@ -278,6 +281,81 @@ void main() { check(backgroundColor()).isSameColorAs(MessageListTheme.dark.streamMessageBgDefault); }); + group('fetch initial batch of messages', () { + group('topic permalink', () { + final someStream = eg.stream(); + const someTopic = 'some topic'; + + final otherStream = eg.stream(); + const otherTopic = 'other topic'; + + testWidgets('with message move', (tester) async { + final narrow = TopicNarrow(someStream.streamId, eg.t(someTopic), with_: 1); + await setupMessageListPage(tester, + narrow: narrow, + // server sends the /with/ message in its current, different location + messages: [eg.streamMessage(id: 1, stream: otherStream, topic: otherTopic)], + streams: [someStream, otherStream], + skipPumpAndSettle: true); + await tester.pump(); // global store loaded + await tester.pump(); // per-account store loaded + + // Until we learn the conversation was moved, + // we put the link's stream/topic in the app bar. + checkAppBarChannelTopic(someStream.name, someTopic); + + await tester.pumpAndSettle(); // initial message fetch plus anything else + + // When we learn the conversation was moved, + // we put the new stream/topic in the app bar. + checkAppBarChannelTopic(otherStream.name, otherTopic); + + // We followed the move in just one fetch. + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages') + ..url.queryParameters.deepEquals({ + 'narrow': jsonEncode(narrow.apiEncode()), + 'anchor': AnchorCode.newest.toJson(), + 'num_before': kMessageListFetchBatchSize.toString(), + 'num_after': '0', + }); + }); + + testWidgets('without message move', (tester) async { + final narrow = TopicNarrow(someStream.streamId, eg.t(someTopic), with_: 1); + await setupMessageListPage(tester, + narrow: narrow, + // server sends the /with/ message in its current, different location + messages: [eg.streamMessage(id: 1, stream: someStream, topic: someTopic)], + streams: [someStream], + skipPumpAndSettle: true); + await tester.pump(); // global store loaded + await tester.pump(); // per-account store loaded + + // Until we learn if the conversation was moved, + // we put the link's stream/topic in the app bar. + checkAppBarChannelTopic(someStream.name, someTopic); + + await tester.pumpAndSettle(); // initial message fetch plus anything else + + // There was no move, so we're still showing the same stream/topic. + checkAppBarChannelTopic(someStream.name, someTopic); + + // We only made one fetch. + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages') + ..url.queryParameters.deepEquals({ + 'narrow': jsonEncode(narrow.apiEncode()), + 'anchor': AnchorCode.newest.toJson(), + 'num_before': kMessageListFetchBatchSize.toString(), + 'num_after': '0', + }); + }); + }); + }); + group('fetch older messages on scroll', () { int? itemCount(WidgetTester tester) => tester.widget(find.byType(CustomScrollView)).semanticChildCount; From 6d6dc6dd965d5bc986a03a262ea575fed8afd5c2 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 22 Jan 2025 17:15:19 -0500 Subject: [PATCH 106/110] compose [nfc]: Derive hintText from topic display name Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 6dcb1ac5fe..996ef2a9bb 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -577,13 +577,13 @@ class _StreamContentInputState extends State<_StreamContentInput> { final zulipLocalizations = ZulipLocalizations.of(context); final streamName = store.streams[widget.narrow.streamId]?.name ?? zulipLocalizations.unknownChannelName; + final topic = TopicName(widget.controller.topic.textNormalized); return _ContentInput( narrow: widget.narrow, - destination: TopicNarrow(widget.narrow.streamId, - TopicName(widget.controller.topic.textNormalized)), + destination: TopicNarrow(widget.narrow.streamId, topic), controller: widget.controller, - hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, - widget.controller.topic.textNormalized)); + hintText: zulipLocalizations.composeBoxChannelContentHint( + streamName, topic.displayName)); } } From c4f9341d2721ea0b927141436df69c7b9ab6c5c4 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 4 Feb 2025 17:19:44 -0500 Subject: [PATCH 107/110] compose [nfc]: Extract isTopicVacuous helper This will later (in another PR) be useful for checking emptiness when realmEmptyTopicDisplayName is given. Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 996ef2a9bb..7c841a3d6b 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -160,10 +160,17 @@ class ComposeTopicController extends ComposeController { return trimmed.isEmpty ? kNoTopicTopic : trimmed; } + /// Whether [textNormalized] would fail a mandatory-topics check + /// (see [mandatory]). + /// + /// The term "Vacuous" draws distinction from [String.isEmpty], in the sense + /// that certain strings are not empty but also indicate the absence of a topic. + bool get isTopicVacuous => textNormalized == kNoTopicTopic; + @override List _computeValidationErrors() { return [ - if (mandatory && textNormalized == kNoTopicTopic) + if (mandatory && isTopicVacuous) TopicValidationError.mandatoryButEmpty, if ( From 3523bc0204f624e35712793a58ce26701ecefb85 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 22 Jan 2025 18:22:39 -0500 Subject: [PATCH 108/110] compose test: Add tests for hintText Signed-off-by: Zixuan James Li --- test/widgets/compose_box_test.dart | 88 +++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 896dec7efe..52d2d1c851 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -78,14 +78,17 @@ void main() { controller = tester.state(find.byType(ComposeBox)).controller; } + /// A [Finder] for the topic input. + /// + /// To enter some text, use [enterTopic]. + final topicInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeTopicController); + /// Set the topic input's text to [topic], using [WidgetTester.enterText]. Future enterTopic(WidgetTester tester, { required ChannelNarrow narrow, required String topic, }) async { - final topicInputFinder = find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller is ComposeTopicController); - connection.prepare(body: jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); await tester.enterText(topicInputFinder, topic); @@ -318,6 +321,85 @@ void main() { }); }); + group('ComposeBox hintText', () { + final channel = eg.stream(); + + Future prepare(WidgetTester tester, { + required Narrow narrow, + }) async { + await prepareComposeBox(tester, + narrow: narrow, + otherUsers: [eg.otherUser, eg.thirdUser], + streams: [channel]); + } + + /// This checks the input's configured hint text without regard to whether + /// it's currently visible, as it won't be if the user has entered some text. + /// + /// If `topicHintText` is `null`, check that the topic input is not present. + void checkComposeBoxHintTexts(WidgetTester tester, { + String? topicHintText, + required String contentHintText, + }) { + if (topicHintText != null) { + check(tester.widget(topicInputFinder)) + .decoration.isNotNull().hintText.equals(topicHintText); + } else { + check(topicInputFinder).findsNothing(); + } + check(tester.widget(contentInputFinder)) + .decoration.isNotNull().hintText.equals(contentHintText); + } + + group('to ChannelNarrow', () { + testWidgets('with empty topic', (tester) async { + await prepare(tester, narrow: ChannelNarrow(channel.streamId)); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name} > (no topic)'); + }); + + testWidgets('with non-empty topic', (tester) async { + final narrow = ChannelNarrow(channel.streamId); + await prepare(tester, narrow: narrow); + await enterTopic(tester, narrow: narrow, topic: 'new topic'); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name} > new topic'); + }); + }); + + testWidgets('to TopicNarrow', (tester) async { + await prepare(tester, + narrow: TopicNarrow(channel.streamId, TopicName('topic'))); + checkComposeBoxHintTexts(tester, + contentHintText: 'Message #${channel.name} > topic'); + }); + + testWidgets('to DmNarrow with self', (tester) async { + await prepare(tester, narrow: DmNarrow.withUser( + eg.selfUser.userId, selfUserId: eg.selfUser.userId)); + checkComposeBoxHintTexts(tester, + contentHintText: 'Jot down something'); + }); + + testWidgets('to 1:1 DmNarrow', (tester) async { + await prepare(tester, narrow: DmNarrow.withUser( + eg.otherUser.userId, selfUserId: eg.selfUser.userId)); + checkComposeBoxHintTexts(tester, + contentHintText: 'Message @${eg.otherUser.fullName}'); + }); + + testWidgets('to group DmNarrow', (tester) async { + await prepare(tester, narrow: DmNarrow.withOtherUsers( + [eg.otherUser.userId, eg.thirdUser.userId], + selfUserId: eg.selfUser.userId)); + checkComposeBoxHintTexts(tester, + contentHintText: 'Message group'); + }); + }); + group('ComposeBox textCapitalization', () { void checkComposeBoxTextFields(WidgetTester tester, { required bool expectTopicTextField, From a5af8d3766522ab355900027389b434123989a04 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 5 Feb 2025 16:23:08 -0500 Subject: [PATCH 109/110] compose: Avoid translating Zulip message destinations The '#channel > topic' style strings are not supposed to be translated into different languages as they are Zulip's language of expressing the desintation, not something bound to the English language. See also: https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 7 +++---- assets/l10n/app_pl.arb | 14 -------------- assets/l10n/app_ru.arb | 14 -------------- lib/generated/l10n/zulip_localizations.dart | 6 +++--- lib/generated/l10n/zulip_localizations_ar.dart | 4 ++-- lib/generated/l10n/zulip_localizations_en.dart | 4 ++-- lib/generated/l10n/zulip_localizations_ja.dart | 4 ++-- lib/generated/l10n/zulip_localizations_nb.dart | 4 ++-- lib/generated/l10n/zulip_localizations_pl.dart | 4 ++-- lib/generated/l10n/zulip_localizations_ru.dart | 4 ++-- lib/generated/l10n/zulip_localizations_sk.dart | 4 ++-- lib/widgets/compose_box.dart | 12 ++++++++++-- 12 files changed, 30 insertions(+), 51 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 118ab83c70..ec0de4dd8f 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -348,12 +348,11 @@ "@composeBoxSelfDmContentHint": { "description": "Hint text for content input when sending a message to yourself." }, - "composeBoxChannelContentHint": "Message #{channel} > {topic}", + "composeBoxChannelContentHint": "Message {destination}", "@composeBoxChannelContentHint": { - "description": "Hint text for content input when sending a message to a channel", + "description": "Hint text for content input when sending a message to a channel.", "placeholders": { - "channel": {"type": "String", "example": "channel name"}, - "topic": {"type": "String", "example": "topic name"} + "destination": {"type": "String", "example": "#channel name > topic name"} } }, "composeBoxSendTooltip": "Send", diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 770a670212..c43e57e573 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -263,20 +263,6 @@ "@composeBoxSelfDmContentHint": { "description": "Hint text for content input when sending a message to yourself." }, - "composeBoxChannelContentHint": "Wiadomość #{channel} > {topic}", - "@composeBoxChannelContentHint": { - "description": "Hint text for content input when sending a message to a channel", - "placeholders": { - "channel": { - "type": "String", - "example": "channel name" - }, - "topic": { - "type": "String", - "example": "topic name" - } - } - }, "composeBoxTopicHintText": "Wątek", "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index ef38533bb2..5df7840006 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -373,20 +373,6 @@ "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." }, - "composeBoxChannelContentHint": "Сообщение для #{channel} > {topic}", - "@composeBoxChannelContentHint": { - "description": "Hint text for content input when sending a message to a channel", - "placeholders": { - "channel": { - "type": "String", - "example": "channel name" - }, - "topic": { - "type": "String", - "example": "topic name" - } - } - }, "composeBoxSendTooltip": "Отправить", "@composeBoxSendTooltip": { "description": "Tooltip for send button in compose box." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 9579683908..1b339ce823 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -567,11 +567,11 @@ abstract class ZulipLocalizations { /// **'Jot down something'** String get composeBoxSelfDmContentHint; - /// Hint text for content input when sending a message to a channel + /// Hint text for content input when sending a message to a channel. /// /// In en, this message translates to: - /// **'Message #{channel} > {topic}'** - String composeBoxChannelContentHint(String channel, String topic); + /// **'Message {destination}'** + String composeBoxChannelContentHint(String destination); /// Tooltip for send button in compose box. /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 71bf06d8ce..890bf68596 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -276,8 +276,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } @override diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 7a33e33567..72b7e9ad69 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -276,8 +276,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } @override diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 137883e5e9..5bfacf60d9 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -276,8 +276,8 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } @override diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3dec7d9b5a..9698d16c96 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -276,8 +276,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } @override diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 83a777bfc4..01bee756da 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -276,8 +276,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Zanotuj coś na przyszłość'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Wiadomość #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } @override diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 827aaf0155..2c01dddb61 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -276,8 +276,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Сделать заметку'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Сообщение для #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } @override diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 38a3f8a240..a69cfc2d8a 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -276,8 +276,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get composeBoxSelfDmContentHint => 'Jot down something'; @override - String composeBoxChannelContentHint(String channel, String topic) { - return 'Message #$channel > $topic'; + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; } @override diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 7c841a3d6b..0a060a6bcf 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -590,7 +590,11 @@ class _StreamContentInputState extends State<_StreamContentInput> { destination: TopicNarrow(widget.narrow.streamId, topic), controller: widget.controller, hintText: zulipLocalizations.composeBoxChannelContentHint( - streamName, topic.displayName)); + // No i18n of this use of "#" and ">" string; those are part of how + // Zulip expresses channels and topics, not any normal English punctuation, + // so don't make sense to translate. See: + // https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 + '#$streamName > ${topic.displayName}')); } } @@ -649,7 +653,11 @@ class _FixedDestinationContentInput extends StatelessWidget { final streamName = store.streams[streamId]?.name ?? zulipLocalizations.unknownChannelName; return zulipLocalizations.composeBoxChannelContentHint( - streamName, topic.displayName); + // No i18n of this use of "#" and ">" string; those are part of how + // Zulip expresses channels and topics, not any normal English punctuation, + // so don't make sense to translate. See: + // https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 + '#$streamName > ${topic.displayName}'); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. return zulipLocalizations.composeBoxSelfDmContentHint; From 323e6446169e00f9b8b9dace6471ed6c638fa1d7 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Fri, 21 Feb 2025 12:02:07 +0000 Subject: [PATCH 110/110] l10n: Update translations from Weblate. --- assets/l10n/app_pl.arb | 68 ++++++++++++++++++++++++++++++++++++++---- assets/l10n/app_ru.arb | 4 --- assets/l10n/app_sk.arb | 4 --- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index c43e57e573..0cc88f2159 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -585,7 +585,7 @@ "@composeBoxSendTooltip": { "description": "Tooltip for send button in compose box." }, - "messageListGroupYouWithYourself": "Ty ze sobą", + "messageListGroupYouWithYourself": "Zapiski na własne konto", "@messageListGroupYouWithYourself": { "description": "Message list recipient header for a DM group that only includes yourself." }, @@ -597,10 +597,6 @@ "@aboutPageTitle": { "description": "Title for About Zulip page." }, - "errorLoginCouldNotConnectTitle": "Nie można połączyć", - "@errorLoginCouldNotConnectTitle": { - "description": "Error title when the app could not connect to the server." - }, "contentValidationErrorEmpty": "Nie masz nic do wysłania!", "@contentValidationErrorEmpty": { "description": "Content validation error message when the message is empty." @@ -886,5 +882,67 @@ "unpinnedSubscriptionsLabel": "Odpięte", "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." + }, + "actionSheetOptionResolveTopic": "Oznacz jako rozwiązany", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "actionSheetOptionUnresolveTopic": "Oznacz brak rozwiązania", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Nie udało się oznaczyć jako rozwiązany", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "Nie udało się oznaczyć brak rozwiązania", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "filenameAndSizeInMiB": "{filename}: {size} MiB", + "@filenameAndSizeInMiB": { + "description": "The name of a file, and its size in mebibytes.", + "placeholders": { + "filename": { + "type": "String", + "example": "foo.txt" + }, + "size": { + "type": "String", + "example": "20.2" + } + } + }, + "errorInvalidApiKeyMessage": "Konto w ramach {url} nie zostało przyjęte. Spróbuj ponownie lub skorzystaj z innego konta.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorCouldNotConnectTitle": "Brak połączenia", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "composeBoxChannelContentHint": "Wiadomość do {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 5df7840006..b0717b14ab 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -325,10 +325,6 @@ } } }, - "errorLoginCouldNotConnectTitle": "Не удалось подключиться", - "@errorLoginCouldNotConnectTitle": { - "description": "Error title when the app could not connect to the server." - }, "errorMessageDoesNotSeemToExist": "Это сообщение, похоже, отсутствует.", "@errorMessageDoesNotSeemToExist": { "description": "Error message when loading a message that does not exist." diff --git a/assets/l10n/app_sk.arb b/assets/l10n/app_sk.arb index 087f459697..ba700eb33c 100644 --- a/assets/l10n/app_sk.arb +++ b/assets/l10n/app_sk.arb @@ -171,10 +171,6 @@ } } }, - "errorLoginCouldNotConnectTitle": "Nepodarilo sa pripojiť", - "@errorLoginCouldNotConnectTitle": { - "description": "Error title when the app could not connect to the server." - }, "errorMessageDoesNotSeemToExist": "Správa zrejme neexistuje.", "@errorMessageDoesNotSeemToExist": { "description": "Error message when loading a message that does not exist."