From 187a022f31067772be6615a6c0411b490f3c82d7 Mon Sep 17 00:00:00 2001 From: lakshya1goel Date: Thu, 1 May 2025 09:19:55 +0530 Subject: [PATCH 001/290] lightbox: Prevent hero animation between message lists Fixes #930. Fixes #44. --- lib/widgets/content.dart | 3 +- lib/widgets/lightbox.dart | 40 +++++++---- test/widgets/lightbox_test.dart | 115 ++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 0305edde91..40b510305d 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -651,6 +651,7 @@ class MessageImage extends StatelessWidget { Navigator.of(context).push(getImageLightboxRoute( context: context, message: message, + messageImageContext: context, src: resolvedSrcUrl, thumbnailUrl: resolvedThumbnailUrl, originalWidth: node.originalWidth, @@ -659,7 +660,7 @@ class MessageImage extends StatelessWidget { child: node.loading ? const CupertinoActivityIndicator() : resolvedSrcUrl == null ? null : LightboxHero( - message: message, + messageImageContext: context, src: resolvedSrcUrl, child: RealmContentNetworkImage( resolvedThumbnailUrl ?? resolvedSrcUrl, diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index a66e4137dd..94847db241 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -15,45 +15,55 @@ import 'dialog.dart'; import 'page.dart'; import 'store.dart'; -// TODO(#44): Add index of the image preview in the message, to not break if -// there are multiple image previews with the same URL in the same -// message. Maybe keep `src`, so that on exit the lightbox image doesn't -// fly to an image preview with a different URL, following a message edit -// while the lightbox was open. class _LightboxHeroTag { - _LightboxHeroTag({required this.messageId, required this.src}); + _LightboxHeroTag({ + required this.messageImageContext, + required this.src, + }); - final int messageId; + /// The [BuildContext] of the image in the message list that's being expanded + /// into the lightbox. Used to coordinate the Hero animation between this specific + /// image and the lightbox view. + /// + /// This helps ensure the animation only happens between the correct image instances, + /// preventing unwanted animations between different message lists or between + /// different images that happen to have the same URL. + // TODO: write a regression test for #44, duplicate images within a message + final BuildContext messageImageContext; + + /// The image source URL. Used to match the source and destination images + /// during the Hero animation, ensuring the animation only occurs between + /// images showing the same content. final Uri src; @override bool operator ==(Object other) { return other is _LightboxHeroTag && - other.messageId == messageId && + other.messageImageContext == messageImageContext && other.src == src; } @override - int get hashCode => Object.hash('_LightboxHeroTag', messageId, src); + int get hashCode => Object.hash('_LightboxHeroTag', messageImageContext, src); } /// Builds a [Hero] from an image in the message list to the lightbox page. class LightboxHero extends StatelessWidget { const LightboxHero({ super.key, - required this.message, + required this.messageImageContext, required this.src, required this.child, }); - final Message message; + final BuildContext messageImageContext; final Uri src; final Widget child; @override Widget build(BuildContext context) { return Hero( - tag: _LightboxHeroTag(messageId: message.id, src: src), + tag: _LightboxHeroTag(messageImageContext: messageImageContext, src: src), flightShuttleBuilder: ( BuildContext flightContext, Animation animation, @@ -226,6 +236,7 @@ class _ImageLightboxPage extends StatefulWidget { const _ImageLightboxPage({ required this.routeEntranceAnimation, required this.message, + required this.messageImageContext, required this.src, required this.thumbnailUrl, required this.originalWidth, @@ -234,6 +245,7 @@ class _ImageLightboxPage extends StatefulWidget { final Animation routeEntranceAnimation; final Message message; + final BuildContext messageImageContext; final Uri src; final Uri? thumbnailUrl; final double? originalWidth; @@ -317,7 +329,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> { child: InteractiveViewer( child: SafeArea( child: LightboxHero( - message: widget.message, + messageImageContext: widget.messageImageContext, src: widget.src, child: RealmContentNetworkImage(widget.src, filterQuality: FilterQuality.medium, @@ -599,6 +611,7 @@ Route getImageLightboxRoute({ int? accountId, BuildContext? context, required Message message, + required BuildContext messageImageContext, required Uri src, required Uri? thumbnailUrl, required double? originalWidth, @@ -611,6 +624,7 @@ Route getImageLightboxRoute({ return _ImageLightboxPage( routeEntranceAnimation: animation, message: message, + messageImageContext: messageImageContext, src: src, thumbnailUrl: thumbnailUrl, originalWidth: originalWidth, diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 31a4132a7c..fda7122123 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:checks/checks.dart'; import 'package:clock/clock.dart'; @@ -10,12 +11,18 @@ import 'package:video_player_platform_interface/video_player_platform_interface. import 'package:video_player/video_player.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/lightbox.dart'; +import 'package:zulip/widgets/message_list.dart'; +import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; +import '../model/content_test.dart'; +import '../model/test_store.dart'; import '../test_images.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -197,6 +204,113 @@ class FakeVideoPlayerPlatform extends Fake void main() { TestZulipBinding.ensureInitialized(); + group('LightboxHero', () { + late PerAccountStore store; + late FakeApiConnection connection; + + final channel = eg.stream(); + final message = eg.streamMessage(stream: channel, + topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html); + + // From ContentExample.imageSingle. + final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp'; + final imageSrcUrl = Uri.parse(imageSrcUrlStr); + final imageFinder = find.byWidgetPredicate( + (widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl); + + Future setupMessageListPage(WidgetTester tester) async { + addTearDown(testBinding.reset); + final subscription = eg.subscription(channel); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + streams: [channel], subscriptions: [subscription])); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + await store.addUser(eg.selfUser); + + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: MessageListPage(initNarrow: const CombinedFeedNarrow()))); + await tester.pumpAndSettle(); + } + + testWidgets('Hero animation occurs smoothly when opening lightbox from message list', (tester) async { + double dist(Rect a, Rect b) => + sqrt(pow(a.top - b.top, 2) + pow(a.left - b.left, 2)); + + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester); + + final initialImagePosition = tester.getRect(imageFinder); + await tester.tap(imageFinder); + await tester.pump(); + // pump to start hero animation + await tester.pump(); + + const heroAnimationDuration = Duration(milliseconds: 300); + const steps = 150; + final stepDuration = heroAnimationDuration ~/ steps; + final animatedPositions = []; + for (int i = 1; i <= steps; i++) { + await tester.pump(stepDuration); + animatedPositions.add(tester.getRect(imageFinder)); + } + + final totalDistance = dist(initialImagePosition, animatedPositions.last); + Rect previousPosition = initialImagePosition; + double maxStepDistance = 0.0; + for (final position in animatedPositions) { + final stepDistance = dist(previousPosition, position); + maxStepDistance = max(maxStepDistance, stepDistance); + check(position).not((pos) => pos.equals(previousPosition)); + + previousPosition = position; + } + check(maxStepDistance).isLessThan(0.03 * totalDistance); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('no hero animation occurs between different message list pages for same image', (tester) async { + Rect getElementRect(Element element) => + tester.getRect(find.byElementPredicate((e) => e == element)); + + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester); + + final firstElement = tester.element(imageFinder); + final firstImagePosition = getElementRect(firstElement); + + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); + await tester.tap(find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text('test topic'))); + await tester.pumpAndSettle(); + + final secondElement = tester.element(imageFinder); + final secondImagePosition = getElementRect(secondElement); + + await tester.tap(find.byType(BackButton)); + await tester.pump(); + + const heroAnimationDuration = Duration(milliseconds: 300); + const steps = 150; + final stepDuration = heroAnimationDuration ~/ steps; + for (int i = 0; i < steps; i++) { + await tester.pump(stepDuration); + check(tester.elementList(imageFinder)) + .unorderedEquals([firstElement, secondElement]); + check(getElementRect(firstElement)).equals(firstImagePosition); + check(getElementRect(secondElement)).equals(secondImagePosition); + } + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('_ImageLightboxPage', () { final src = Uri.parse('https://chat.example/lightbox-image.png'); @@ -216,6 +330,7 @@ void main() { unawaited(navigator.push(getImageLightboxRoute( accountId: eg.selfAccount.id, message: message ?? eg.streamMessage(), + messageImageContext: navigator.context, src: src, thumbnailUrl: thumbnailUrl, originalHeight: null, From f3da7044062ec6c39635677699430dbc1c8c8873 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 13:42:48 -0700 Subject: [PATCH 002/290] lightbox [nfc]: Tighten and clarify docs on _LightboxHeroTag --- lib/widgets/lightbox.dart | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 94847db241..5b51d3e909 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -15,25 +15,37 @@ import 'dialog.dart'; import 'page.dart'; import 'store.dart'; +/// Identifies which [LightboxHero]s should match up with each other +/// to produce a hero animation. +/// +/// See [Hero.tag], the field where we use instances of this class. +/// +/// The intended behavior is that when the user acts on an image +/// in the message list to have the app expand it in the lightbox, +/// a hero animation goes from the original view of the image +/// to the version in the lightbox, +/// and back to the original upon exiting the lightbox. class _LightboxHeroTag { _LightboxHeroTag({ required this.messageImageContext, required this.src, }); - /// The [BuildContext] of the image in the message list that's being expanded - /// into the lightbox. Used to coordinate the Hero animation between this specific - /// image and the lightbox view. + /// The [BuildContext] for the [MessageImage] being expanded into the lightbox. /// - /// This helps ensure the animation only happens between the correct image instances, - /// preventing unwanted animations between different message lists or between - /// different images that happen to have the same URL. + /// In particular this prevents hero animations between + /// different message lists that happen to have the same message. + /// It also distinguishes different copies of the same image + /// in a given message list. // TODO: write a regression test for #44, duplicate images within a message final BuildContext messageImageContext; - /// The image source URL. Used to match the source and destination images - /// during the Hero animation, ensuring the animation only occurs between - /// images showing the same content. + /// The image source URL. + /// + /// This ensures the animation only occurs between matching images, even if + /// the message was edited before navigating back to the message list + /// so that the original [MessageImage] has been replaced in the tree + /// by a different image. final Uri src; @override From 237859d5c446f5a1a703c79f10b8c768ecff772b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 1 May 2025 15:43:39 -0700 Subject: [PATCH 003/290] store [nfc]: Move store.sendMessage up near other MessageStore proxy impls Thanks Greg for noticing this: https://github.com/zulip/zulip-flutter/pull/1484#discussion_r2070836515 --- lib/model/store.dart | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 939120113e..be25737b38 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -737,6 +737,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor void unregisterMessageList(MessageListView view) => _messages.unregisterMessageList(view); @override + Future sendMessage({required MessageDestination destination, required String content}) { + assert(!_disposed); + return _messages.sendMessage(destination: destination, content: content); + } + @override void reconcileMessages(List messages) { _messages.reconcileMessages(messages); // TODO(#649) notify [unreads] of the just-fetched messages @@ -904,12 +909,6 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor } } - @override - Future sendMessage({required MessageDestination destination, required String content}) { - assert(!_disposed); - return _messages.sendMessage(destination: destination, content: content); - } - static List _sortCustomProfileFields(List initialCustomProfileFields) { // TODO(server): The realm-wide field objects have an `order` property, // but the actual API appears to be that the fields should be shown in From e7708d6eff65f615711ab20884061019898eca47 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 23 Apr 2025 19:54:11 -0700 Subject: [PATCH 004/290] model: Implement edit-message methods on MessageStore Related: #126 --- lib/model/message.dart | 87 +++++++++++ lib/model/store.dart | 15 ++ test/model/message_test.dart | 269 +++++++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+) diff --git a/lib/model/message.dart b/lib/model/message.dart index fd5de1adbd..cd1e869a16 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -35,6 +35,36 @@ mixin MessageStore { /// All [Message] objects in the resulting list will be present in /// [this.messages]. void reconcileMessages(List messages); + + /// Whether the current edit request for the given message, if any, has failed. + /// + /// Will be null if there is no current edit request. + /// Will be false if the current request hasn't failed + /// and the update-message event hasn't arrived. + bool? getEditMessageErrorStatus(int messageId); + + /// Edit a message's content, via a request to the server. + /// + /// Should only be called when there is no current edit request for [messageId], + /// i.e., [getEditMessageErrorStatus] returns null for [messageId]. + /// + /// See also: + /// * [getEditMessageErrorStatus] + /// * [takeFailedMessageEdit] + void editMessage({required int messageId, required String newContent}); + + /// Forgets the failed edit request and returns the attempted new content. + /// + /// Should only be called when there is a failed request, + /// per [getEditMessageErrorStatus]. + String takeFailedMessageEdit(int messageId); +} + +class _EditMessageRequestStatus { + _EditMessageRequestStatus({required this.hasError, required this.newContent}); + + bool hasError; + final String newContent; } class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @@ -132,6 +162,56 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } } + @override + bool? getEditMessageErrorStatus(int messageId) => + _editMessageRequests[messageId]?.hasError; + + final Map _editMessageRequests = {}; + + @override + void editMessage({ + required int messageId, + required String newContent, + }) async { + if (_editMessageRequests.containsKey(messageId)) { + throw StateError('an edit request is already in progress'); + } + + _editMessageRequests[messageId] = _EditMessageRequestStatus( + hasError: false, newContent: newContent); + _notifyMessageListViewsForOneMessage(messageId); + try { + await updateMessage(connection, messageId: messageId, content: newContent); + // On success, we'll clear the status from _editMessageRequests + // when we get the event. + } catch (e) { + // TODO(log) if e is something unexpected + + final status = _editMessageRequests[messageId]; + if (status == null) { + // The event actually arrived before this request failed + // (can happen with network issues). + // Or, the message was deleted. + return; + } + status.hasError = true; + _notifyMessageListViewsForOneMessage(messageId); + } + } + + @override + String takeFailedMessageEdit(int messageId) { + final status = _editMessageRequests.remove(messageId); + _notifyMessageListViewsForOneMessage(messageId); + if (status == null) { + throw StateError('called takeFailedMessageEdit, but no edit'); + } + if (!status.hasError) { + throw StateError("called takeFailedMessageEdit, but edit hasn't failed"); + } + return status.newContent; + } + void handleUserTopicEvent(UserTopicEvent event) { for (final view in _messageListViews) { view.handleUserTopicEvent(event); @@ -183,6 +263,12 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // The message is guaranteed to be edited. // See also: https://zulip.com/api/get-events#update_message message.editState = MessageEditState.edited; + + // Clear the edit-message progress feedback. + // This makes a rare bug where we might clear the feedback too early, + // if the user raced with themself to edit the same message + // from multiple clients. + _editMessageRequests.remove(message.id); } if (event.renderedContent != null) { assert(message.contentType == 'text/html', @@ -245,6 +331,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { void handleDeleteMessageEvent(DeleteMessageEvent event) { for (final messageId in event.messageIds) { messages.remove(messageId); + _editMessageRequests.remove(messageId); } for (final view in _messageListViews) { view.handleDeleteMessageEvent(event); diff --git a/lib/model/store.dart b/lib/model/store.dart index be25737b38..1414589c34 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -747,6 +747,21 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor // TODO(#649) notify [unreads] of the just-fetched messages // TODO(#650) notify [recentDmConversationsView] of the just-fetched messages } + @override + bool? getEditMessageErrorStatus(int messageId) { + assert(!_disposed); + return _messages.getEditMessageErrorStatus(messageId); + } + @override + void editMessage({required int messageId, required String newContent}) { + assert(!_disposed); + return _messages.editMessage(messageId: messageId, newContent: newContent); + } + @override + String takeFailedMessageEdit(int messageId) { + assert(!_disposed); + return _messages.takeFailedMessageEdit(messageId); + } @override Set get debugMessageListViews => _messages.debugMessageListViews; diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 25e89b0a54..ed11093071 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1,10 +1,13 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; +import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -13,6 +16,7 @@ import '../api/fake_api.dart'; import '../api/model/model_checks.dart'; import '../api/model/submessage_checks.dart'; import '../example_data.dart' as eg; +import '../fake_async.dart'; import '../stdlib_checks.dart'; import 'message_list_test.dart'; import 'store_checks.dart'; @@ -123,6 +127,271 @@ void main() { }); }); + group('edit-message methods', () { + late StreamMessage message; + Future prepareEditMessage() async { + await prepare(); + message = eg.streamMessage(); + await prepareMessages([message]); + check(connection.takeRequests()).length.equals(1); // message-list fetchInitial + } + + void checkRequest(int messageId, String content) { + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'content': content, + }); + } + + test('smoke', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkRequest(message.id, 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + + async.elapse(Duration(milliseconds: 500)); + // Request has succeeded; event hasn't arrived + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('concurrent edits on different messages', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + final otherMessage = eg.streamMessage(); + await store.addMessage(otherMessage); + checkNotifiedOnce(); + + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkRequest(message.id, 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-first request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNull(); + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: otherMessage.id, newContent: 'other message new content'); + checkRequest(otherMessage.id, 'other message new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // First request has succeeded; event hasn't arrived + // Mid-second request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNotNull().isFalse(); + checkNotNotified(); + + // First event arrives + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Second request has succeeded; event hasn't arrived + check(store.getEditMessageErrorStatus(otherMessage.id)).isNotNull().isFalse(); + checkNotNotified(); + + // Second event arrives + await store.handleEvent(eg.updateMessageEditEvent(otherMessage)); + check(store.getEditMessageErrorStatus(otherMessage.id)).isNull(); + checkNotifiedOnce(); + })); + + test('request fails', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + })); + + test('request fails; take failed edit', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + check(store.takeFailedMessageEdit(message.id)).equals('new content'); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('takeFailedMessageEdit throws StateError when nothing to take', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + check(() => store.takeFailedMessageEdit(message.id)).throws(); + })); + + test('editMessage throws StateError if editMessage already in progress for same message', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + async.elapse(Duration(milliseconds: 500)); + check(connection.takeRequests()).length.equals(1); + checkNotifiedOnce(); + + await check(store.editMessage(messageId: message.id, newContent: 'newer content')) + .isA>().throws(); + check(connection.takeRequests()).isEmpty(); + })); + + test('event arrives, then request fails', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.flushTimers(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + + test('request fails, then event arrives', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('request fails, then event arrives; take failed edit in between', () => awaitFakeAsync((async) async { + // This can happen with network issues. + + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + httpException: const SocketException('failed'), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + check(store.takeFailedMessageEdit(message.id)).equals('new content'); + checkNotifiedOnce(); + + await store.handleEvent(eg.updateMessageEditEvent(message)); // no error + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); // content updated + })); + + test('request fails, then message deleted', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkNotifiedOnce(); + async.elapse(Duration(seconds: 1)); + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); + checkNotifiedOnce(); + + await store.handleEvent(eg.deleteMessageEvent([message])); // no error + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + })); + + test('message deleted while request in progress; we get failure response', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.deleteMessageEvent([message])); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Request failure, but status has already been cleared + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + + test('message deleted while request in progress but we get success response', () => awaitFakeAsync((async) async { + await prepareEditMessage(); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, newContent: 'new content'); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Mid-request + check(store.getEditMessageErrorStatus(message.id)).isNotNull().isFalse(); + checkNotNotified(); + + await store.handleEvent(eg.deleteMessageEvent([message])); + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotifiedOnce(); + + async.elapse(Duration(milliseconds: 500)); + // Request success + check(store.getEditMessageErrorStatus(message.id)).isNull(); + checkNotNotified(); + })); + }); + group('handleMessageEvent', () { test('from empty', () async { await prepare(); From 19aa2121e967699dcec52de39a4ccb0c1d6f8bb4 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 30 Apr 2025 19:33:02 -0700 Subject: [PATCH 005/290] api: Add prevContentSha256 param to updateMessage route --- lib/api/route/messages.dart | 2 ++ test/api/route/messages_test.dart | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 6a42158b75..449bf9fd80 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -275,6 +275,7 @@ Future updateMessage( bool? sendNotificationToOldThread, bool? sendNotificationToNewThread, String? content, + String? prevContentSha256, int? streamId, }) { return connection.patch('updateMessage', UpdateMessageResult.fromJson, 'messages/$messageId', { @@ -283,6 +284,7 @@ Future updateMessage( if (sendNotificationToOldThread != null) 'send_notification_to_old_thread': sendNotificationToOldThread, if (sendNotificationToNewThread != null) 'send_notification_to_new_thread': sendNotificationToNewThread, if (content != null) 'content': RawParameter(content), + if (prevContentSha256 != null) 'prev_content_sha256': RawParameter(prevContentSha256), if (streamId != null) 'stream_id': streamId, }); } diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 416fca4f3b..aff49bd8af 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -454,6 +454,7 @@ void main() { bool? sendNotificationToOldThread, bool? sendNotificationToNewThread, String? content, + String? prevContentSha256, int? streamId, required Map expected, }) async { @@ -464,6 +465,7 @@ void main() { sendNotificationToOldThread: sendNotificationToOldThread, sendNotificationToNewThread: sendNotificationToNewThread, content: content, + prevContentSha256: prevContentSha256, streamId: streamId, ); check(connection.lastRequest).isA() @@ -473,6 +475,20 @@ void main() { return result; } + test('pure content change', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: UpdateMessageResult().toJson()); + await checkUpdateMessage(connection, + messageId: eg.streamMessage().id, + content: 'asdf', + prevContentSha256: '34a780ad578b997db55b260beb60b501f3e04d30ba1a51fcf43cd8dd1241780d', + expected: { + 'content': 'asdf', + 'prev_content_sha256': '34a780ad578b997db55b260beb60b501f3e04d30ba1a51fcf43cd8dd1241780d', + }); + }); + }); + test('topic/content change', () { // A separate test exercises `streamId`; // the API doesn't allow changing channel and content at the same time. From 6ff20f66ad979e9d0fd083ad3d1a9da6c431ff50 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 1 May 2025 16:12:23 -0700 Subject: [PATCH 006/290] model: Use prevContentSha256 in edit-message method --- lib/model/message.dart | 14 +++++++-- lib/model/store.dart | 9 ++++-- test/model/message_test.dart | 59 +++++++++++++++++++++++++----------- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index cd1e869a16..1da91e9b0d 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:crypto/crypto.dart'; + import '../api/model/events.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; @@ -51,7 +53,11 @@ mixin MessageStore { /// See also: /// * [getEditMessageErrorStatus] /// * [takeFailedMessageEdit] - void editMessage({required int messageId, required String newContent}); + void editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }); /// Forgets the failed edit request and returns the attempted new content. /// @@ -171,6 +177,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @override void editMessage({ required int messageId, + required String originalRawContent, required String newContent, }) async { if (_editMessageRequests.containsKey(messageId)) { @@ -181,7 +188,10 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { hasError: false, newContent: newContent); _notifyMessageListViewsForOneMessage(messageId); try { - await updateMessage(connection, messageId: messageId, content: newContent); + await updateMessage(connection, + messageId: messageId, + content: newContent, + prevContentSha256: sha256.convert(utf8.encode(originalRawContent)).toString()); // On success, we'll clear the status from _editMessageRequests // when we get the event. } catch (e) { diff --git a/lib/model/store.dart b/lib/model/store.dart index 1414589c34..b3b1b62b98 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -753,9 +753,14 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor return _messages.getEditMessageErrorStatus(messageId); } @override - void editMessage({required int messageId, required String newContent}) { + void editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }) { assert(!_disposed); - return _messages.editMessage(messageId: messageId, newContent: newContent); + return _messages.editMessage(messageId: messageId, + originalRawContent: originalRawContent, newContent: newContent); } @override String takeFailedMessageEdit(int messageId) { diff --git a/test/model/message_test.dart b/test/model/message_test.dart index ed11093071..2ed4d99490 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; @@ -136,11 +137,16 @@ void main() { check(connection.takeRequests()).length.equals(1); // message-list fetchInitial } - void checkRequest(int messageId, String content) { + void checkRequest(int messageId, { + required String prevContent, + required String content, + }) { + final prevContentSha256 = sha256.convert(utf8.encode(prevContent)).toString(); check(connection.takeRequests()).single.isA() ..method.equals('PATCH') ..url.path.equals('/api/v1/messages/$messageId') ..bodyFields.deepEquals({ + 'prev_content_sha256': prevContentSha256, 'content': content, }); } @@ -151,8 +157,11 @@ void main() { connection.prepare( json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); - checkRequest(message.id, 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkRequest(message.id, + prevContent: 'old content', + content: 'new content'); checkNotifiedOnce(); async.elapse(Duration(milliseconds: 500)); @@ -179,8 +188,11 @@ void main() { connection.prepare( json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); - checkRequest(message.id, 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); + checkRequest(message.id, + prevContent: 'old content', + content: 'new content'); checkNotifiedOnce(); async.elapse(Duration(milliseconds: 500)); @@ -189,8 +201,11 @@ void main() { check(store.getEditMessageErrorStatus(otherMessage.id)).isNull(); connection.prepare( json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); - store.editMessage(messageId: otherMessage.id, newContent: 'other message new content'); - checkRequest(otherMessage.id, 'other message new content'); + store.editMessage(messageId: otherMessage.id, + originalRawContent: 'other message old content', newContent: 'other message new content'); + checkRequest(otherMessage.id, + prevContent: 'other message old content', + content: 'other message new content'); checkNotifiedOnce(); async.elapse(Duration(milliseconds: 500)); @@ -221,7 +236,8 @@ void main() { check(store.getEditMessageErrorStatus(message.id)).isNull(); connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); checkNotifiedOnce(); async.elapse(Duration(seconds: 1)); check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); @@ -233,7 +249,8 @@ void main() { check(store.getEditMessageErrorStatus(message.id)).isNull(); connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); checkNotifiedOnce(); async.elapse(Duration(seconds: 1)); check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); @@ -255,12 +272,14 @@ void main() { connection.prepare( json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); async.elapse(Duration(milliseconds: 500)); check(connection.takeRequests()).length.equals(1); checkNotifiedOnce(); - await check(store.editMessage(messageId: message.id, newContent: 'newer content')) + await check(store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'newer content')) .isA>().throws(); check(connection.takeRequests()).isEmpty(); })); @@ -273,7 +292,8 @@ void main() { connection.prepare( httpException: const SocketException('failed'), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); checkNotifiedOnce(); async.elapse(Duration(milliseconds: 500)); @@ -294,7 +314,8 @@ void main() { connection.prepare( httpException: const SocketException('failed'), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); checkNotifiedOnce(); async.elapse(Duration(seconds: 1)); @@ -314,7 +335,8 @@ void main() { connection.prepare( httpException: const SocketException('failed'), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); checkNotifiedOnce(); async.elapse(Duration(seconds: 1)); @@ -333,7 +355,8 @@ void main() { check(store.getEditMessageErrorStatus(message.id)).isNull(); connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); checkNotifiedOnce(); async.elapse(Duration(seconds: 1)); check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); @@ -349,7 +372,8 @@ void main() { check(store.getEditMessageErrorStatus(message.id)).isNull(); connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); checkNotifiedOnce(); async.elapse(Duration(milliseconds: 500)); @@ -373,7 +397,8 @@ void main() { connection.prepare( json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); - store.editMessage(messageId: message.id, newContent: 'new content'); + store.editMessage(messageId: message.id, + originalRawContent: 'old content', newContent: 'new content'); checkNotifiedOnce(); async.elapse(Duration(milliseconds: 500)); From 5fcf6e060127efad38efb7d7a7982fbbaf75bdb7 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 29 Apr 2025 21:58:29 -0400 Subject: [PATCH 007/290] theme: Compute colorSwatchFor when subscription is null This removes the ad-hoc color we use for null subscriptions, which is a workaround for not having logic to generate different base colors within constraints for unsubscribed channels: https://chat.zulip.org/#narrow/channel/48-mobile/topic/All.20channels.20screen.20-.20.23F832/near/1904009 While this change does not implement that logic either, it removes the ad-hoc color in favor of a constant base color, intended for colorSwatchFor. The base color can be reused on topic-list page (#1158), giving us access to `.iconOnBarBackground` and friends for the app bar. --- lib/widgets/message_list.dart | 33 ++++++--------------------------- lib/widgets/theme.dart | 13 +++++++++++-- test/widgets/theme_test.dart | 8 ++++++++ 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 90a0762d34..c9e20ce87a 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -43,9 +43,6 @@ class MessageListTheme extends ThemeExtension { unreadMarker: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor(), unreadMarkerGap: Colors.white.withValues(alpha: 0.6), - - // TODO(design) this seems ad-hoc; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xfff5f5f5), ); static final dark = MessageListTheme._( @@ -63,9 +60,6 @@ class MessageListTheme extends ThemeExtension { unreadMarker: const HSLColor.fromAHSL(0.75, 227, 0.78, 0.59).toColor(), unreadMarkerGap: Colors.transparent, - - // TODO(design) this is ad-hoc and untested; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xff0a0a0a), ); MessageListTheme._({ @@ -76,7 +70,6 @@ class MessageListTheme extends ThemeExtension { required this.streamRecipientHeaderChevronRight, required this.unreadMarker, required this.unreadMarkerGap, - required this.unsubscribedStreamRecipientHeaderBg, }); /// The [MessageListTheme] from the context's active theme. @@ -96,7 +89,6 @@ class MessageListTheme extends ThemeExtension { final Color streamRecipientHeaderChevronRight; final Color unreadMarker; final Color unreadMarkerGap; - final Color unsubscribedStreamRecipientHeaderBg; @override MessageListTheme copyWith({ @@ -107,7 +99,6 @@ class MessageListTheme extends ThemeExtension { Color? streamRecipientHeaderChevronRight, Color? unreadMarker, Color? unreadMarkerGap, - Color? unsubscribedStreamRecipientHeaderBg, }) { return MessageListTheme._( bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, @@ -117,7 +108,6 @@ class MessageListTheme extends ThemeExtension { streamRecipientHeaderChevronRight: streamRecipientHeaderChevronRight ?? this.streamRecipientHeaderChevronRight, unreadMarker: unreadMarker ?? this.unreadMarker, unreadMarkerGap: unreadMarkerGap ?? this.unreadMarkerGap, - unsubscribedStreamRecipientHeaderBg: unsubscribedStreamRecipientHeaderBg ?? this.unsubscribedStreamRecipientHeaderBg, ); } @@ -134,7 +124,6 @@ class MessageListTheme extends ThemeExtension { streamRecipientHeaderChevronRight: Color.lerp(streamRecipientHeaderChevronRight, other.streamRecipientHeaderChevronRight, t)!, unreadMarker: Color.lerp(unreadMarker, other.unreadMarker, t)!, unreadMarkerGap: Color.lerp(unreadMarkerGap, other.unreadMarkerGap, t)!, - unsubscribedStreamRecipientHeaderBg: Color.lerp(unsubscribedStreamRecipientHeaderBg, other.unsubscribedStreamRecipientHeaderBg, t)!, ); } } @@ -225,9 +214,8 @@ class _MessageListPageState extends State implements MessageLis case ChannelNarrow(:final streamId): case TopicNarrow(:final streamId): final subscription = store.subscriptions[streamId]; - appBarBackgroundColor = subscription != null - ? colorSwatchFor(context, subscription).barBackground - : messageListTheme.unsubscribedStreamRecipientHeaderBg; + appBarBackgroundColor = + colorSwatchFor(context, subscription).barBackground; // All recipient headers will match this color; remove distracting line // (but are recipient headers even needed for topic narrows?) removeAppBarBottomBorder = true; @@ -1091,24 +1079,15 @@ class StreamMessageRecipientHeader extends StatelessWidget { // https://github.com/zulip/zulip-mobile/issues/5511 final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); + final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final streamId = message.conversation.streamId; final topic = message.conversation.topic; - final messageListTheme = MessageListTheme.of(context); - - final subscription = store.subscriptions[streamId]; - final Color backgroundColor; - final Color iconColor; - if (subscription != null) { - final swatch = colorSwatchFor(context, subscription); - backgroundColor = swatch.barBackground; - iconColor = swatch.iconOnBarBackground; - } else { - backgroundColor = messageListTheme.unsubscribedStreamRecipientHeaderBg; - iconColor = designVariables.title; - } + final swatch = colorSwatchFor(context, store.subscriptions[streamId]); + final backgroundColor = swatch.barBackground; + final iconColor = swatch.iconOnBarBackground; final Widget streamWidget; if (!_containsDifferentChannels(narrow)) { diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index eea9677045..a533311869 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -554,10 +554,19 @@ class DesignVariables extends ThemeExtension { } } +// This is taken from: +// https://github.com/zulip/zulip/blob/b248e2d93/web/src/stream_data.ts#L40 +const kDefaultChannelColorSwatchBaseColor = 0xffc2c2c2; + /// The theme-appropriate [ChannelColorSwatch] based on [subscription.color]. /// +/// If [subscription] is null, [ChannelColorSwatch] will be based on +/// [kDefaultChannelColorSwatchBaseColor]. +/// /// For how this value is cached, see [ChannelColorSwatches.forBaseColor]. -ChannelColorSwatch colorSwatchFor(BuildContext context, Subscription subscription) { +// TODO(#188) pick different colors for unsubscribed channels +ChannelColorSwatch colorSwatchFor(BuildContext context, Subscription? subscription) { return DesignVariables.of(context) - .channelColorSwatches.forBaseColor(subscription.color); + .channelColorSwatches.forBaseColor( + subscription?.color ?? kDefaultChannelColorSwatchBaseColor); } diff --git a/test/widgets/theme_test.dart b/test/widgets/theme_test.dart index 8510d42b7c..678736767d 100644 --- a/test/widgets/theme_test.dart +++ b/test/widgets/theme_test.dart @@ -178,5 +178,13 @@ void main() { check(colorSwatchFor(element, subscription)) .isSameColorSwatchAs(ChannelColorSwatch.dark(baseColor)); }); + + testWidgets('fallback to default base color when no subscription', (tester) async { + await tester.pumpWidget(const TestZulipApp()); + await tester.pump(); + final element = tester.element(find.byType(Placeholder)); + check(colorSwatchFor(element, null)).isSameColorSwatchAs( + ChannelColorSwatch.light(kDefaultChannelColorSwatchBaseColor)); + }); }); } From 9e2d9e5f79f4a80085f03f14a74d204287a1d32e Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 17:16:35 -0400 Subject: [PATCH 008/290] msglist [nfc]: Extract _SenderRow widget --- lib/widgets/message_list.dart | 52 ++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index c9e20ce87a..7670c6e8ca 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1338,14 +1338,13 @@ String formatHeaderDate( } } -/// A Zulip message, showing the sender's name and avatar if specified. -// Design referenced from: -// - https://github.com/zulip/zulip-mobile/issues/5511 -// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev -class MessageWithPossibleSender extends StatelessWidget { - const MessageWithPossibleSender({super.key, required this.item}); +// TODO(i18n): web seems to ignore locale in formatting time, but we could do better +final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); - final MessageListMessageItem item; +class _SenderRow extends StatelessWidget { + const _SenderRow({required this.message}); + + final Message message; @override Widget build(BuildContext context) { @@ -1353,14 +1352,12 @@ class MessageWithPossibleSender extends StatelessWidget { final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); - final message = item.message; final sender = store.getUser(message.senderId); - - Widget? senderRow; - if (item.showSender) { - final time = _kMessageTimestampFormat - .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); - senderRow = Row( + final time = _kMessageTimestampFormat + .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), + child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), @@ -1400,8 +1397,23 @@ class MessageWithPossibleSender extends StatelessWidget { height: (18 / 16), fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], ).merge(weightVariableTextStyle(context))), - ]); - } + ])); + } +} + +/// A Zulip message, showing the sender's name and avatar if specified. +// Design referenced from: +// - https://github.com/zulip/zulip-mobile/issues/5511 +// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev +class MessageWithPossibleSender extends StatelessWidget { + const MessageWithPossibleSender({super.key, required this.item}); + + final MessageListMessageItem item; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final message = item.message; final localizations = ZulipLocalizations.of(context); String? editStateText; @@ -1430,9 +1442,8 @@ class MessageWithPossibleSender extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column(children: [ - if (senderRow != null) - Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), - child: senderRow), + if (item.showSender) + _SenderRow(message: message), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), @@ -1460,6 +1471,3 @@ class MessageWithPossibleSender extends StatelessWidget { ]))); } } - -// TODO(i18n): web seems to ignore locale in formatting time, but we could do better -final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); From 2d7d83dfd20c1403b53c6f24bd9fb9a85a90ad11 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 27 Mar 2025 17:06:27 -0400 Subject: [PATCH 009/290] msglist [nfc]: Add showTimestamp to _SenderRow widget This is for the upcoming local-echo feature, to hide the timestamp. There isn't a Figma design for messages without a timestamp. --- lib/widgets/message_list.dart | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 7670c6e8ca..4605820c07 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1342,9 +1342,10 @@ String formatHeaderDate( final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); class _SenderRow extends StatelessWidget { - const _SenderRow({required this.message}); + const _SenderRow({required this.message, required this.showTimestamp}); final Message message; + final bool showTimestamp; @override Widget build(BuildContext context) { @@ -1389,14 +1390,16 @@ class _SenderRow extends StatelessWidget { ), ], ]))), - const SizedBox(width: 4), - Text(time, - style: TextStyle( - color: messageListTheme.labelTime, - fontSize: 16, - height: (18 / 16), - fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], - ).merge(weightVariableTextStyle(context))), + if (showTimestamp) ...[ + const SizedBox(width: 4), + Text(time, + style: TextStyle( + color: messageListTheme.labelTime, + fontSize: 16, + height: (18 / 16), + fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], + ).merge(weightVariableTextStyle(context))), + ], ])); } } @@ -1443,7 +1446,7 @@ class MessageWithPossibleSender extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 4), child: Column(children: [ if (item.showSender) - _SenderRow(message: message), + _SenderRow(message: message, showTimestamp: true), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), From fb6b53bc69c0ad329cc2779b85f82235d75138d8 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 17:29:52 -0400 Subject: [PATCH 010/290] api [nfc]: Extract Conversation.isSameAs This will make it easier to support comparing the conversations between subclasses of MessageBase. The message list tests on displayRecipient are now mostly exercising the logic on Conversation.isSameAs, which makes it reasonable to move the tests. Keep them here for now since this logic is more relevant to message lists than it is to the rest of the app. Dropped the comment on _equalIdSequences since it is now private in the short body of DmConversation. --- lib/api/model/model.dart | 27 ++++++++++++++++++++++++++- lib/model/message_list.dart | 31 +------------------------------ 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 90769e815b..4c12403b85 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -614,7 +614,10 @@ extension type const TopicName(String _value) { /// Different from [MessageDestination], this information comes from /// [getMessages] or [getEvents], identifying the conversation that contains a /// message. -sealed class Conversation {} +sealed class Conversation { + /// Whether [this] and [other] refer to the same Zulip conversation. + bool isSameAs(Conversation other); +} /// The conversation a stream message is in. @JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) @@ -640,6 +643,13 @@ class StreamConversation extends Conversation { factory StreamConversation.fromJson(Map json) => _$StreamConversationFromJson(json); + + @override + bool isSameAs(Conversation other) { + return other is StreamConversation + && streamId == other.streamId + && topic.isSameAs(other.topic); + } } /// The conversation a DM message is in. @@ -653,6 +663,21 @@ class DmConversation extends Conversation { DmConversation({required this.allRecipientIds}) : assert(isSortedWithoutDuplicates(allRecipientIds.toList())); + + bool _equalIdSequences(Iterable xs, Iterable ys) { + if (xs.length != ys.length) return false; + final xs_ = xs.iterator; final ys_ = ys.iterator; + while (xs_.moveNext() && ys_.moveNext()) { + if (xs_.current != ys_.current) return false; + } + return true; + } + + @override + bool isSameAs(Conversation other) { + if (other is! DmConversation) return false; + return _equalIdSequences(allRecipientIds, other.allRecipientIds); + } } /// A message or message-like object, for showing in a message list. diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 58a0e1bb95..97dc07c77b 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -351,26 +351,7 @@ mixin _MessageSequence { @visibleForTesting bool haveSameRecipient(Message prevMessage, Message message) { - if (prevMessage is StreamMessage && message is StreamMessage) { - if (prevMessage.streamId != message.streamId) return false; - if (prevMessage.topic.canonicalize() != message.topic.canonicalize()) return false; - } else if (prevMessage is DmMessage && message is DmMessage) { - if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) { - return false; - } - } else { - return false; - } - return true; - - // switch ((prevMessage, message)) { - // case (StreamMessage(), StreamMessage()): - // // TODO(dart-3): this doesn't type-narrow prevMessage and message - // case (DmMessage(), DmMessage()): - // // … - // default: - // return false; - // } + return prevMessage.conversation.isSameAs(message.conversation); } @visibleForTesting @@ -382,16 +363,6 @@ bool messagesSameDay(Message prevMessage, Message message) { return true; } -// Intended for [Message.allRecipientIds]. Assumes efficient `length`. -bool _equalIdSequences(Iterable xs, Iterable ys) { - if (xs.length != ys.length) return false; - final xs_ = xs.iterator; final ys_ = ys.iterator; - while (xs_.moveNext() && ys_.moveNext()) { - if (xs_.current != ys_.current) return false; - } - return true; -} - bool _sameDay(DateTime date1, DateTime date2) { if (date1.year != date2.year) return false; if (date1.month != date2.month) return false; From 7a47731b0a6844a88485d876ee76d04348975472 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 4 Apr 2025 17:37:46 -0400 Subject: [PATCH 011/290] msglist [nfc]: Extract MessageListMessageBaseItem This is NFC because MessageListMessageItem is still the only subclass of it. --- lib/model/message_list.dart | 46 ++++++++++++++++++++----------- test/model/message_list_test.dart | 8 ++++-- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 97dc07c77b..2e4c4fb600 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -35,18 +35,31 @@ class MessageListDateSeparatorItem extends MessageListItem { MessageListDateSeparatorItem(this.message); } -/// A message to show in the message list. -class MessageListMessageItem extends MessageListItem { - final Message message; - ZulipMessageContent content; +/// A [MessageBase] to show in the message list. +sealed class MessageListMessageBaseItem extends MessageListItem { + MessageBase get message; + ZulipMessageContent get content; bool showSender; bool isLastInBlock; + MessageListMessageBaseItem({ + required this.showSender, + required this.isLastInBlock, + }); +} + +/// A [Message] to show in the message list. +class MessageListMessageItem extends MessageListMessageBaseItem { + @override + final Message message; + @override + ZulipMessageContent content; + MessageListMessageItem( this.message, this.content, { - required this.showSender, - required this.isLastInBlock, + required super.showSender, + required super.isLastInBlock, }); } @@ -350,12 +363,12 @@ mixin _MessageSequence { } @visibleForTesting -bool haveSameRecipient(Message prevMessage, Message message) { +bool haveSameRecipient(MessageBase prevMessage, MessageBase message) { return prevMessage.conversation.isSameAs(message.conversation); } @visibleForTesting -bool messagesSameDay(Message prevMessage, Message message) { +bool messagesSameDay(MessageBase prevMessage, MessageBase message) { // TODO memoize [DateTime]s... also use memoized for showing date/time in msglist final prevTime = DateTime.fromMillisecondsSinceEpoch(prevMessage.timestamp * 1000); final time = DateTime.fromMillisecondsSinceEpoch(message.timestamp * 1000); @@ -410,19 +423,20 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// one way or another. /// /// See also [_allMessagesVisible]. - bool _messageVisible(Message message) { + bool _messageVisible(MessageBase message) { switch (narrow) { case CombinedFeedNarrow(): - return switch (message) { - StreamMessage() => - store.isTopicVisible(message.streamId, message.topic), - DmMessage() => true, + return switch (message.conversation) { + StreamConversation(:final streamId, :final topic) => + store.isTopicVisible(streamId, topic), + DmConversation() => true, }; case ChannelNarrow(:final streamId): - assert(message is StreamMessage && message.streamId == streamId); - if (message is! StreamMessage) return false; - return store.isTopicVisibleInStream(streamId, message.topic); + assert(message is MessageBase + && message.conversation.streamId == streamId); + if (message is! MessageBase) return false; + return store.isTopicVisibleInStream(streamId, message.conversation.topic); case TopicNarrow(): case DmNarrow(): diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index f92bdfc096..04bf5c3bde 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -2001,13 +2001,17 @@ extension MessageListDateSeparatorItemChecks on Subject get message => has((x) => x.message, 'message'); } -extension MessageListMessageItemChecks on Subject { - Subject get message => has((x) => x.message, 'message'); +extension MessageListMessageBaseItemChecks on Subject { + Subject get message => has((x) => x.message, 'message'); Subject get content => has((x) => x.content, 'content'); Subject get showSender => has((x) => x.showSender, 'showSender'); Subject get isLastInBlock => has((x) => x.isLastInBlock, 'isLastInBlock'); } +extension MessageListMessageItemChecks on Subject { + Subject get message => has((x) => x.message, 'message'); +} + extension MessageListViewChecks on Subject { Subject get store => has((x) => x.store, 'store'); Subject get narrow => has((x) => x.narrow, 'narrow'); From 88fba8314b7ba0edfd6a8ac0bc718934f0cb331a Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 7 Apr 2025 15:37:23 -0400 Subject: [PATCH 012/290] msglist [nfc]: Let MessageItem take a MessageListMessageBaseItem This will make MessageItem compatible with other future subclasses of MessageListMessageBaseItem, in particular MessageListOutboxMessageItem, which do not need unread markers. --- lib/widgets/message_list.dart | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 4605820c07..ae7c51b67c 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -992,25 +992,32 @@ class MessageItem extends StatelessWidget { this.trailingWhitespace, }); - final MessageListMessageItem item; + final MessageListMessageBaseItem item; final Widget header; final double? trailingWhitespace; @override Widget build(BuildContext context) { - final message = item.message; final messageListTheme = MessageListTheme.of(context); + + final item = this.item; + Widget child = ColoredBox( + color: messageListTheme.bgMessageRegular, + child: Column(children: [ + switch (item) { + MessageListMessageItem() => MessageWithPossibleSender(item: item), + }, + if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), + ])); + if (item case MessageListMessageItem(:final message)) { + child = _UnreadMarker( + isRead: message.flags.contains(MessageFlag.read), + child: child); + } return StickyHeaderItem( allowOverflow: !item.isLastInBlock, header: header, - child: _UnreadMarker( - isRead: message.flags.contains(MessageFlag.read), - child: ColoredBox( - color: messageListTheme.bgMessageRegular, - child: Column(children: [ - MessageWithPossibleSender(item: item), - if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), - ])))); + child: child); } } From 1e8f2c257286fab7d9778d589179ce069d3d0c46 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 8 Apr 2025 16:15:23 -0400 Subject: [PATCH 013/290] msglist test [nfc]: Add MessageEvent test group --- test/model/message_list_test.dart | 56 ++++++++++++++++--------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 04bf5c3bde..4aedea1d03 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -360,37 +360,39 @@ void main() { }); }); - test('MessageEvent', () async { - final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage(stream: stream))); + group('MessageEvent', () { + test('in narrow', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); - check(model).messages.length.equals(30); - await store.addMessage(eg.streamMessage(stream: stream)); - checkNotifiedOnce(); - check(model).messages.length.equals(31); - }); + check(model).messages.length.equals(30); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotifiedOnce(); + check(model).messages.length.equals(31); + }); - test('MessageEvent, not in narrow', () async { - final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage(stream: stream))); + test('not in narrow', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); - check(model).messages.length.equals(30); - final otherStream = eg.stream(); - await store.addMessage(eg.streamMessage(stream: otherStream)); - checkNotNotified(); - check(model).messages.length.equals(30); - }); + check(model).messages.length.equals(30); + final otherStream = eg.stream(); + await store.addMessage(eg.streamMessage(stream: otherStream)); + checkNotNotified(); + check(model).messages.length.equals(30); + }); - test('MessageEvent, before fetch', () async { - final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await store.addMessage(eg.streamMessage(stream: stream)); - checkNotNotified(); - check(model).fetched.isFalse(); + test('before fetch', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotNotified(); + check(model).fetched.isFalse(); + }); }); group('UserTopicEvent', () { From 91699172c6fbd87293e1fcff65cc7ac7527ff65a Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 25 Apr 2025 18:54:10 -0400 Subject: [PATCH 014/290] test: Bump recentZulipFeatureLevel to FL 382, from FL 278 To make sure that recentZulipFeatureLevel is both recent and reasonable, the new value is taken from the changelog, at the time this was committed: https://github.com/zulip/zulip/blob/c6660fbea/api_docs/changelog.md?plain=1#L23 As a result, this assumes support for empty topics in our app code. To make sure we are aware of possible behavior changes in the app code we test against. Here's a helpful `git grep` command and its result (thanks to "zulipFeatureLevel" being quite a greppable name): ``` $ git grep 'zulipFeatureLevel [<>]' lib lib/api/model/narrow.dart: final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7) lib/api/model/narrow.dart: final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9) lib/model/autocomplete.dart: final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9) lib/model/autocomplete.dart: final isTopicWildcardAvailable = store.zulipFeatureLevel >= 224; // TODO(server-8) lib/model/compose.dart: final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9) lib/model/compose.dart: final isTopicWildcardAvailable = store.zulipFeatureLevel >= 224; // TODO(server-8) lib/model/store.dart: if (zulipFeatureLevel >= 163) { // TODO(server-7) lib/model/store.dart: bool get isUnsupported => zulipFeatureLevel < kMinSupportedZulipFeatureLevel; lib/widgets/action_sheet.dart: final supportsUnmutingTopics = store.zulipFeatureLevel >= 170; lib/widgets/action_sheet.dart: final supportsFollowingTopics = store.zulipFeatureLevel >= 219; lib/widgets/action_sheet.dart: final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6) lib/widgets/actions.dart: final useLegacy = store.zulipFeatureLevel < 155; // TODO(server-6) lib/widgets/actions.dart: assert(PerAccountStoreWidget.of(context).zulipFeatureLevel >= 155); // TODO(server-6) lib/widgets/autocomplete.dart: final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9) lib/widgets/compose_box.dart: if (store.zulipFeatureLevel < 334) { lib/widgets/compose_box.dart: if (store.zulipFeatureLevel >= 334) { ``` We can tell that this bump only affects 2 entries from above: ``` lib/widgets/compose_box.dart: if (store.zulipFeatureLevel < 334) { lib/widgets/compose_box.dart: if (store.zulipFeatureLevel >= 334) { ``` All are related to the FL 334 (general chat) changes. --- test/example_data.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/example_data.dart b/test/example_data.dart index fc3acfc5a4..7b517aca79 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -77,7 +77,7 @@ final Uri realmUrl = Uri.parse('https://chat.example/'); Uri get _realmUrl => realmUrl; const String recentZulipVersion = '9.0'; -const int recentZulipFeatureLevel = 278; +const int recentZulipFeatureLevel = 382; const int futureZulipFeatureLevel = 9999; const int ancientZulipFeatureLevel = kMinSupportedZulipFeatureLevel - 1; From 7f203273d57ec7985a7e1c39c40fbe20c90f51b5 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 25 Apr 2025 18:54:10 -0400 Subject: [PATCH 015/290] store [nfc]: Assert that FL>=334 when accessing realmEmptyTopicName --- lib/model/store.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/model/store.dart b/lib/model/store.dart index b3b1b62b98..300b7dc22f 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -577,6 +577,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor /// be empty otherwise. // TODO(server-10) simplify this String get realmEmptyTopicDisplayName { + assert(zulipFeatureLevel >= 334); assert(_realmEmptyTopicDisplayName != null); // TODO(log) return _realmEmptyTopicDisplayName ?? 'general chat'; } From 5700b42961097ef44cb44edae34da95993703309 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 24 Mar 2025 19:17:54 -0400 Subject: [PATCH 016/290] compose: Fix empty topics not being shown correctly in hint text Before, if the user chooses an empty topic, the hint text might appear as: "#issues > ". Now, with this fix, the hint text will be (correctly): "#issues > general chat" This buggy logic was introduced in 769cc7df, which assumed that TopicName.displayName is `null` when the topic is empty. TopicName that came from the server are guaranteed to be non-empty, but here our code can construct an empty TopicName, breaking this assumption. To enable this fix while the rest of the app still relies on TopicName.displayName being non-nullable, switch to using plain strings here for now. Later once TopicName.displayName becomes nullable, we'll go back to constructing a TopicName here. --- lib/widgets/compose_box.dart | 12 ++++++------ test/widgets/compose_box_test.dart | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 11d27d5402..1c03cf34b2 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -624,7 +624,7 @@ class _StreamContentInputState extends State<_StreamContentInput> { } /// The topic name to show in the hint text, or null to show no topic. - TopicName? _hintTopic() { + String? _hintTopicStr() { if (widget.controller.topic.isTopicVacuous) { if (widget.controller.topic.mandatory) { // The chosen topic can't be sent to, so don't show it. @@ -639,7 +639,7 @@ class _StreamContentInputState extends State<_StreamContentInput> { } } - return TopicName(widget.controller.topic.textNormalized); + return widget.controller.topic.textNormalized; } @override @@ -649,15 +649,15 @@ class _StreamContentInputState extends State<_StreamContentInput> { final streamName = store.streams[widget.narrow.streamId]?.name ?? zulipLocalizations.unknownChannelName; - final hintTopic = _hintTopic(); - final hintDestination = hintTopic == null + final hintTopicStr = _hintTopicStr(); + final hintDestination = hintTopicStr == null // 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' - // ignore: dead_null_aware_expression // null topic names soon to be enabled - : '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}'; + : '#$streamName > ' + '${hintTopicStr.isEmpty ? store.realmEmptyTopicDisplayName : hintTopicStr}'; return _TypingNotifier( destination: TopicNarrow(widget.narrow.streamId, diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 65740a8d1e..44be72d3ff 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -404,7 +404,7 @@ void main() { topicHintText: 'Topic', contentHintText: 'Message #${channel.name} > ' '${eg.defaultRealmEmptyTopicDisplayName}'); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('legacy: with empty topic, content input has focus', (tester) async { await prepare(tester, narrow: narrow, mandatoryTopics: false, From 25eace6ba100498e33010f5fc4ec272c7aa1e0cb Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 16:11:20 -0400 Subject: [PATCH 017/290] compose [nfc]: Convert _TopicInput to a StatefulWidget --- lib/widgets/compose_box.dart | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 1c03cf34b2..eee6d30cdc 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -670,12 +670,17 @@ class _StreamContentInputState extends State<_StreamContentInput> { } } -class _TopicInput extends StatelessWidget { +class _TopicInput extends StatefulWidget { const _TopicInput({required this.streamId, required this.controller}); final int streamId; final StreamComposeBoxController controller; + @override + State<_TopicInput> createState() => _TopicInputState(); +} + +class _TopicInputState extends State<_TopicInput> { @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); @@ -687,18 +692,18 @@ class _TopicInput extends StatelessWidget { ).merge(weightVariableTextStyle(context, wght: 600)); return TopicAutocomplete( - streamId: streamId, - controller: controller.topic, - focusNode: controller.topicFocusNode, - contentFocusNode: controller.contentFocusNode, + streamId: widget.streamId, + controller: widget.controller.topic, + focusNode: widget.controller.topicFocusNode, + contentFocusNode: widget.controller.contentFocusNode, fieldViewBuilder: (context) => Container( padding: const EdgeInsets.only(top: 10, bottom: 9), decoration: BoxDecoration(border: Border(bottom: BorderSide( width: 1, color: designVariables.foreground.withFadedAlpha(0.2)))), child: TextField( - controller: controller.topic, - focusNode: controller.topicFocusNode, + controller: widget.controller.topic, + focusNode: widget.controller.topicFocusNode, textInputAction: TextInputAction.next, style: topicTextStyle, decoration: InputDecoration( From b4344c723f162b8b183a0fcab7a5a671c393bb6b Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 17:41:40 -0400 Subject: [PATCH 018/290] compose: Change topic input hint text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is similar to web's behavior. When topics are not mandatory: - an alternative hint text "Enter a topic (skip for “general chat”)" is shown when the topic input has focus; - an opaque placeholder text (e.g.: "general chat") is shown if the user skipped to content input; Because the topic input is always shown in a message list page channel narrow (assuming permission to send messages), this also adds an initial state: - a short hint text, "Topic", is shown if the user hasn't interacted with topic or content inputs at all, or when the user unfocused topic input without moving focus to content input. This only changes the topic input's hint text. See CZO discussion for design details: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/general.20chat.20design.20.23F1297/near/2106736 --- 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 + .../l10n/zulip_localizations_uk.dart | 5 + lib/widgets/compose_box.dart | 156 +++++++++++++++++- test/flutter_checks.dart | 2 + test/widgets/compose_box_test.dart | 67 +++++++- 13 files changed, 268 insertions(+), 10 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..d2d7b53033 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -379,6 +379,13 @@ "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." }, + "composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": {"type": "String", "example": "general chat"} + } + }, "composeBoxUploadingFilename": "Uploading {filename}…", "@composeBoxUploadingFilename": { "description": "Placeholder in compose box showing the specified file is currently uploading.", diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index e326703e4b..6181c7b39b 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -622,6 +622,12 @@ abstract class ZulipLocalizations { /// **'Topic'** String get composeBoxTopicHintText; + /// Hint text for topic input widget in compose box when topics are optional. + /// + /// In en, this message translates to: + /// **'Enter a topic (skip for “{defaultTopicName}”)'** + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName); + /// Placeholder in compose box showing the specified file is currently uploading. /// /// 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 8d36fa6bd0..f26b9d017c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a74a2e1eaf..bfb14645d5 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index c11a3fae23..35088555e2 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d23bd323fd..ae79d99ea9 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index e1a6bd45f4..15f61bd882 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -323,6 +323,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Wątek'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Przekazywanie $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 78b68e812a..e4248179e2 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -324,6 +324,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Тема'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Загрузка $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 193ac26d8e..baded578ba 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index c1898631fd..50b1029dd7 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -325,6 +325,11 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Тема'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Завантаження $filename…'; diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index eee6d30cdc..fe576f95fc 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -681,16 +681,115 @@ class _TopicInput extends StatefulWidget { } class _TopicInputState extends State<_TopicInput> { + void _topicOrContentFocusChanged() { + setState(() { + final status = widget.controller.topicInteractionStatus; + if (widget.controller.topicFocusNode.hasFocus) { + // topic input gains focus + status.value = ComposeTopicInteractionStatus.isEditing; + } else if (widget.controller.contentFocusNode.hasFocus) { + // content input gains focus + status.value = ComposeTopicInteractionStatus.hasChosen; + } else { + // neither input has focus, the new value of topicInteractionStatus + // depends on its previous value + if (status.value == ComposeTopicInteractionStatus.isEditing) { + // topic input loses focus + status.value = ComposeTopicInteractionStatus.notEditingNotChosen; + } else { + // content input loses focus; stay in hasChosen + assert(status.value == ComposeTopicInteractionStatus.hasChosen); + } + } + }); + } + + void _topicInteractionStatusChanged() { + setState(() { + // The actual state lives in widget.controller.topicInteractionStatus + }); + } + + @override + void initState() { + super.initState(); + widget.controller.topicFocusNode.addListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.addListener(_topicOrContentFocusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } + + @override + void didUpdateWidget(covariant _TopicInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.topicFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.topicFocusNode.addListener(_topicOrContentFocusChanged); + oldWidget.controller.contentFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.addListener(_topicOrContentFocusChanged); + oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } + } + + @override + void dispose() { + widget.controller.topicFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.contentFocusNode.removeListener(_topicOrContentFocusChanged); + widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + super.dispose(); + } + @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); - TextStyle topicTextStyle = TextStyle( + final store = PerAccountStoreWidget.of(context); + + final topicTextStyle = TextStyle( fontSize: 20, height: 22 / 20, color: designVariables.textInput.withFadedAlpha(0.9), ).merge(weightVariableTextStyle(context, wght: 600)); + // TODO(server-10) simplify away + final emptyTopicsSupported = store.zulipFeatureLevel >= 334; + + final String hintText; + TextStyle hintStyle = topicTextStyle.copyWith( + color: designVariables.textInput.withFadedAlpha(0.5)); + + if (store.realmMandatoryTopics) { + // Something short and not distracting. + hintText = zulipLocalizations.composeBoxTopicHintText; + } else { + switch (widget.controller.topicInteractionStatus.value) { + case ComposeTopicInteractionStatus.notEditingNotChosen: + // Something short and not distracting. + hintText = zulipLocalizations.composeBoxTopicHintText; + case ComposeTopicInteractionStatus.isEditing: + // The user is actively interacting with the input. Since topics are + // not mandatory, show a long hint text mentioning that they can be + // left empty. + hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText( + emptyTopicsSupported + ? store.realmEmptyTopicDisplayName + : kNoTopicTopic); + case ComposeTopicInteractionStatus.hasChosen: + // The topic has likely been chosen. Since topics are not mandatory, + // show the default topic display name as if the user has entered that + // when they left the input empty. + if (emptyTopicsSupported) { + hintText = store.realmEmptyTopicDisplayName; + hintStyle = topicTextStyle.copyWith(fontStyle: FontStyle.italic); + } else { + hintText = kNoTopicTopic; + hintStyle = topicTextStyle; + } + } + } + + final decoration = InputDecoration(hintText: hintText, hintStyle: hintStyle); + return TopicAutocomplete( streamId: widget.streamId, controller: widget.controller.topic, @@ -706,10 +805,7 @@ class _TopicInputState extends State<_TopicInput> { focusNode: widget.controller.topicFocusNode, textInputAction: TextInputAction.next, style: topicTextStyle, - decoration: InputDecoration( - hintText: zulipLocalizations.composeBoxTopicHintText, - hintStyle: topicTextStyle.copyWith( - color: designVariables.textInput.withFadedAlpha(0.5)))))); + decoration: decoration))); } } @@ -1382,17 +1478,67 @@ sealed class ComposeBoxController { } } +/// Represent how a user has interacted with topic and content inputs. +/// +/// State-transition diagram: +/// +/// ``` +/// (default) +/// Topic input │ Content input +/// lost focus. ▼ gained focus. +/// ┌────────────► notEditingNotChosen ────────────┐ +/// │ │ │ +/// │ Topic input │ │ +/// │ gained focus. │ │ +/// │ ◄─────────────────────────┘ ▼ +/// isEditing ◄───────────────────────────── hasChosen +/// │ Focus moved from ▲ │ ▲ +/// │ content to topic. │ │ │ +/// │ │ │ │ +/// └──────────────────────────────────────┘ └─────┘ +/// Focus moved from Content input loses focus +/// topic to content. without topic input gaining it. +/// ``` +/// +/// This state machine offers the following invariants: +/// - When topic input has focus, the status must be [isEditing]. +/// - When content input has focus, the status must be [hasChosen]. +/// - When neither input has focus, and content input was the last +/// input among the two to be focused, the status must be [hasChosen]. +/// - Otherwise, the status must be [notEditingNotChosen]. +enum ComposeTopicInteractionStatus { + /// The topic has likely not been chosen if left empty, + /// and is not being actively edited. + /// + /// When in this status neither the topic input nor the content input has focus. + notEditingNotChosen, + + /// The topic is being actively edited. + /// + /// When in this status, the topic input must have focus. + isEditing, + + /// The topic has likely been chosen, even if it is left empty. + /// + /// When in this status, the topic input must have no focus; + /// the content input might have focus. + hasChosen, +} + class StreamComposeBoxController extends ComposeBoxController { StreamComposeBoxController({required PerAccountStore store}) : topic = ComposeTopicController(store: store); final ComposeTopicController topic; final topicFocusNode = FocusNode(); + final ValueNotifier topicInteractionStatus = + ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen); @override void dispose() { topic.dispose(); topicFocusNode.dispose(); + topicInteractionStatus.dispose(); super.dispose(); } } diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index df2777aac6..295dcde7b9 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -83,6 +83,7 @@ extension TextStyleChecks on Subject { Subject get inherit => has((t) => t.inherit, 'inherit'); Subject get color => has((t) => t.color, 'color'); Subject get fontSize => has((t) => t.fontSize, 'fontSize'); + Subject get fontStyle => has((t) => t.fontStyle, 'fontStyle'); Subject get fontWeight => has((t) => t.fontWeight, 'fontWeight'); Subject get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing'); Subject?> get fontVariations => has((t) => t.fontVariations, 'fontVariations'); @@ -228,6 +229,7 @@ extension ThemeDataChecks on Subject { extension InputDecorationChecks on Subject { Subject get hintText => has((x) => x.hintText, 'hintText'); + Subject get hintStyle => has((x) => x.hintStyle, 'hintStyle'); } extension TextFieldChecks on Subject { diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 44be72d3ff..0048b38073 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -372,7 +372,8 @@ void main() { await enterTopic(tester, narrow: narrow, topic: ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', contentHintText: 'Message #${channel.name}'); }); @@ -382,7 +383,7 @@ void main() { await enterTopic(tester, narrow: narrow, topic: ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic (skip for “(no topic)”)', contentHintText: 'Message #${channel.name}'); }); @@ -391,6 +392,40 @@ void main() { await enterTopic(tester, narrow: narrow, topic: eg.defaultRealmEmptyTopicDisplayName); await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, topic input has focus, then content input gains focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + }); + + testWidgets('with empty topic, topic input has focus, then loses it', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + + FocusManager.instance.primaryFocus!.unfocus(); + await tester.pump(); checkComposeBoxHintTexts(tester, topicHintText: 'Topic', contentHintText: 'Message #${channel.name}'); @@ -401,9 +436,11 @@ void main() { await enterContent(tester, ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: eg.defaultRealmEmptyTopicDisplayName, contentHintText: 'Message #${channel.name} > ' '${eg.defaultRealmEmptyTopicDisplayName}'); + check(tester.widget(topicInputFinder)).decoration.isNotNull() + .hintStyle.isNotNull().fontStyle.equals(FontStyle.italic); }); testWidgets('legacy: with empty topic, content input has focus', (tester) async { @@ -412,8 +449,27 @@ void main() { await enterContent(tester, ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: '(no topic)', contentHintText: 'Message #${channel.name} > (no topic)'); + check(tester.widget(topicInputFinder)).decoration.isNotNull() + .hintStyle.isNotNull().fontStyle.isNull(); + }); + + testWidgets('with empty topic, content input has focus, then topic input gains focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); }); testWidgets('with non-empty topic', (tester) async { @@ -421,7 +477,8 @@ void main() { await enterTopic(tester, narrow: narrow, topic: 'new topic'); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', contentHintText: 'Message #${channel.name} > new topic'); }); }); From 070e7206220726ee8808d7e91a71da808123fadc Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 17:45:42 -0400 Subject: [PATCH 019/290] compose: Change content input hint text statefully Before, the content input, when empty, shows the "#stream > general chat" hint text as long as it has focus, and shows "#stream" as the hint text when it loses focus. Now, when the content input is empty, it still shows "#stream > general chat" when it gains focus, except that it will keep showing it even after losing focus, until the user moves focus to the topic input. --- lib/widgets/compose_box.dart | 22 +++++++++++++++++----- test/widgets/compose_box_test.dart | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index fe576f95fc..433f113da9 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -596,11 +596,18 @@ class _StreamContentInputState extends State<_StreamContentInput> { }); } + void _topicInteractionStatusChanged() { + setState(() { + // The relevant state lives on widget.controller.topicInteractionStatus itself. + }); + } + @override void initState() { super.initState(); widget.controller.topic.addListener(_topicChanged); widget.controller.contentFocusNode.addListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); } @override @@ -614,12 +621,17 @@ class _StreamContentInputState extends State<_StreamContentInput> { oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged); widget.controller.contentFocusNode.addListener(_contentFocusChanged); } + if (widget.controller.topicInteractionStatus != oldWidget.controller.topicInteractionStatus) { + oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } } @override void dispose() { widget.controller.topic.removeListener(_topicChanged); widget.controller.contentFocusNode.removeListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); super.dispose(); } @@ -630,11 +642,11 @@ class _StreamContentInputState extends State<_StreamContentInput> { // The chosen topic can't be sent to, so don't show it. return null; } - if (!widget.controller.contentFocusNode.hasFocus) { - // Do not fall back to a vacuous topic unless the user explicitly chooses - // to do so (by skipping topic input and moving focus to content input), - // so that the user is not encouraged to use vacuous topic when they - // have not interacted with the inputs at all. + if (widget.controller.topicInteractionStatus.value != + ComposeTopicInteractionStatus.hasChosen) { + // Do not fall back to a vacuous topic unless the user explicitly + // chooses to do so, so that the user is not encouraged to use vacuous + // topic before they have interacted with the inputs at all. return null; } } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 0048b38073..08106f8be0 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -472,6 +472,23 @@ void main() { contentHintText: 'Message #${channel.name}'); }); + testWidgets('with empty topic, content input has focus, then loses it', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + + FocusManager.instance.primaryFocus!.unfocus(); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + }); + testWidgets('with non-empty topic', (tester) async { await prepare(tester, narrow: narrow, mandatoryTopics: false); await enterTopic(tester, narrow: narrow, topic: 'new topic'); From 4c198de6ea9c5d23e1e156cb21e4989c3eaac0d9 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 21 Jan 2025 11:28:24 -0500 Subject: [PATCH 020/290] api: Make displayName nullable --- lib/api/model/model.dart | 6 +----- lib/model/autocomplete.dart | 2 -- lib/widgets/action_sheet.dart | 4 +--- lib/widgets/autocomplete.dart | 2 -- lib/widgets/compose_box.dart | 13 +++++-------- lib/widgets/inbox.dart | 2 -- lib/widgets/message_list.dart | 4 ---- test/api/model/model_checks.dart | 2 +- test/widgets/action_sheet_test.dart | 5 ++--- test/widgets/autocomplete_test.dart | 8 ++++---- test/widgets/compose_box_test.dart | 2 +- test/widgets/inbox_test.dart | 2 +- test/widgets/message_list_test.dart | 6 +++--- 13 files changed, 19 insertions(+), 39 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 4c12403b85..6b2cb4dd02 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -581,11 +581,7 @@ extension type const TopicName(String _value) { /// The string this topic is displayed as to the user in our UI. /// /// At the moment this always equals [apiName]. - /// In the future this will become null for the "general chat" topic (#1250), - /// so that UI code can identify when it needs to represent the topic - /// specially in the way prescribed for "general chat". - // TODO(#1250) carry out that plan - String get displayName => _value; + String? get displayName => _value.isEmpty ? null : _value; /// The key to use for "same topic as" comparisons. String canonicalize() => apiName.toLowerCase(); diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 073255bc9d..8fee7620dc 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -942,13 +942,11 @@ class TopicAutocompleteQuery extends AutocompleteQuery { bool testTopic(TopicName topic, PerAccountStore store) { // TODO(#881): Sort by match relevance, like web does. - // ignore: unnecessary_null_comparison // null topic names soon to be enabled if (topic.displayName == null) { return store.realmEmptyTopicDisplayName.toLowerCase() .contains(raw.toLowerCase()); } return topic.displayName != raw - // ignore: unnecessary_non_null_assertion // null topic names soon to be enabled && topic.displayName!.toLowerCase().contains(raw.toLowerCase()); } diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 88114d48bb..6aa7239a0c 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -304,9 +304,7 @@ void showTopicActionSheet(BuildContext context, { // TODO: check for other cases that may disallow this action (e.g.: time // limit for editing topics). - if (someMessageIdInTopic != null - // ignore: unnecessary_null_comparison // null topic names soon to be enabled - && topic.displayName != null) { + if (someMessageIdInTopic != null && topic.displayName != null) { optionButtons.add(ResolveUnresolveButton(pageContext: pageContext, topic: topic, someMessageIdInTopic: someMessageIdInTopic)); diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index a31369c3d9..a1956295eb 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -416,13 +416,11 @@ class TopicAutocomplete extends AutocompleteField { } void setTopic(TopicName newTopic) { - // ignore: dead_null_aware_expression // null topic names soon to be enabled value = TextEditingValue(text: newTopic.displayName ?? ''); } } @@ -636,7 +635,7 @@ class _StreamContentInputState extends State<_StreamContentInput> { } /// The topic name to show in the hint text, or null to show no topic. - String? _hintTopicStr() { + TopicName? _hintTopic() { if (widget.controller.topic.isTopicVacuous) { if (widget.controller.topic.mandatory) { // The chosen topic can't be sent to, so don't show it. @@ -651,7 +650,7 @@ class _StreamContentInputState extends State<_StreamContentInput> { } } - return widget.controller.topic.textNormalized; + return TopicName(widget.controller.topic.textNormalized); } @override @@ -661,15 +660,14 @@ class _StreamContentInputState extends State<_StreamContentInput> { final streamName = store.streams[widget.narrow.streamId]?.name ?? zulipLocalizations.unknownChannelName; - final hintTopicStr = _hintTopicStr(); - final hintDestination = hintTopicStr == null + final hintTopic = _hintTopic(); + final hintDestination = hintTopic == null // 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' - : '#$streamName > ' - '${hintTopicStr.isEmpty ? store.realmEmptyTopicDisplayName : hintTopicStr}'; + : '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}'; return _TypingNotifier( destination: TopicNarrow(widget.narrow.streamId, @@ -842,7 +840,6 @@ class _FixedDestinationContentInput extends StatelessWidget { // 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 - // ignore: dead_null_aware_expression // null topic names soon to be enabled '#$streamName > ${topic.displayName ?? store.realmEmptyTopicDisplayName}'); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index a8c0c12b59..0f6a5c75a1 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -546,14 +546,12 @@ class _TopicItem extends StatelessWidget { style: TextStyle( fontSize: 17, height: (20 / 17), - // ignore: unnecessary_null_comparison // null topic names soon to be enabled fontStyle: topic.displayName == null ? FontStyle.italic : null, // TODO(design) check if this is the right variable color: designVariables.labelMenuButton, ), maxLines: 2, overflow: TextOverflow.ellipsis, - // ignore: dead_null_aware_expression // null topic names soon to be enabled topic.displayName ?? store.realmEmptyTopicDisplayName))), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index ae7c51b67c..98beda3205 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -326,10 +326,8 @@ class MessageListAppBarTitle extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - // ignore: dead_null_aware_expression // null topic names soon to be enabled Flexible(child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, style: TextStyle( fontSize: 13, - // ignore: unnecessary_null_comparison // null topic names soon to be enabled fontStyle: topic.displayName == null ? FontStyle.italic : null, ).merge(weightVariableTextStyle(context)))), if (icon != null) @@ -1144,13 +1142,11 @@ class StreamMessageRecipientHeader extends StatelessWidget { child: Row( children: [ Flexible( - // ignore: dead_null_aware_expression // null topic names soon to be enabled child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, // TODO: Give a way to see the whole topic (maybe a // long-press interaction?) overflow: TextOverflow.ellipsis, style: recipientHeaderTextStyle(context, - // ignore: unnecessary_null_comparison // null topic names soon to be enabled fontStyle: topic.displayName == null ? FontStyle.italic : null, ))), const SizedBox(width: 4), diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 8791c1b9d9..1a17f70f60 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -26,7 +26,7 @@ extension ZulipStreamChecks on Subject { extension TopicNameChecks on Subject { Subject get apiName => has((x) => x.apiName, 'apiName'); - Subject get displayName => has((x) => x.displayName, 'displayName'); + Subject get displayName => has((x) => x.displayName, 'displayName'); } extension StreamConversationChecks on Subject { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 8aeeec4eed..deda8078e2 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -372,7 +372,6 @@ void main() { final topicRow = find.descendant( of: find.byType(ZulipAppBar), matching: find.text( - // ignore: dead_null_aware_expression // null topic names soon to be enabled effectiveTopic.displayName ?? eg.defaultRealmEmptyTopicDisplayName)); await tester.longPress(topicRow); // sheet appears onscreen; default duration of bottom-sheet enter animation @@ -393,7 +392,7 @@ void main() { await tester.longPress(find.descendant( of: find.byType(RecipientHeader), - matching: find.text(effectiveMessage.topic.displayName))); + matching: find.text(effectiveMessage.topic.displayName!))); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); } @@ -457,7 +456,7 @@ void main() { messages: [message]); check(findButtonForLabel('Mark as resolved')).findsNothing(); check(findButtonForLabel('Mark as unresolved')).findsNothing(); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('show from recipient header', (tester) async { await prepare(); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 484c3b2454..b4ff007a8d 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -415,7 +415,7 @@ void main() { await tester.tap(find.text('Topic three')); await tester.pumpAndSettle(); check(tester.widget(topicInputFinder).controller!.text) - .equals(topic3.name.displayName); + .equals(topic3.name.displayName!); check(find.text('Topic one' )).findsNothing(); check(find.text('Topic two' )).findsNothing(); check(find.text('Topic three')).findsOne(); // shown in `_TopicInput` once @@ -473,7 +473,7 @@ void main() { await tester.pumpAndSettle(); check(find.text('some display name')).findsOne(); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('match realmEmptyTopicDisplayName in autocomplete', (tester) async { final topic = eg.getStreamTopicsEntry(name: ''); @@ -486,7 +486,7 @@ void main() { await tester.pumpAndSettle(); check(find.text('general chat')).findsOne(); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('autocomplete to realmEmptyTopicDisplayName sets topic to empty string', (tester) async { final topic = eg.getStreamTopicsEntry(name: ''); @@ -502,6 +502,6 @@ void main() { await tester.tap(find.text('general chat')); await tester.pump(Duration.zero); check(controller.value).text.equals(''); - }, skip: true); // null topic names soon to be enabled + }); }); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 08106f8be0..f979c687de 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -563,7 +563,7 @@ void main() { narrow: TopicNarrow(channel.streamId, TopicName(''))); checkComposeBoxHintTexts(tester, contentHintText: 'Message #${channel.name} > ${eg.defaultRealmEmptyTopicDisplayName}'); - }, skip: true); // null topic names soon to be enabled + }); }); testWidgets('to DmNarrow with self', (tester) async { diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 9fa5de1bf3..c91b70cb44 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -314,7 +314,7 @@ void main() { subscriptions: [(eg.subscription(channel))], unreadMessages: [eg.streamMessage(stream: channel, topic: '')]); check(find.text(eg.defaultRealmEmptyTopicDisplayName)).findsOne(); - }, skip: true); // null topic names soon to be enabled + }); group('topic visibility', () { final channel = eg.stream(); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 047a036cb5..816cde5948 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -204,7 +204,7 @@ void main() { messageCount: 1); checkAppBarChannelTopic( channel.name, eg.defaultRealmEmptyTopicDisplayName); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; @@ -1015,7 +1015,7 @@ void main() { await tester.pump(); check(findInMessageList('stream name')).single; check(findInMessageList(eg.defaultRealmEmptyTopicDisplayName)).single; - }, skip: true); // null topic names soon to be enabled + }); testWidgets('show general chat for empty topics without channel name', (tester) async { await setupMessageListPage(tester, @@ -1024,7 +1024,7 @@ void main() { await tester.pump(); check(findInMessageList('stream name')).isEmpty(); check(findInMessageList(eg.defaultRealmEmptyTopicDisplayName)).single; - }, skip: true); // null topic names soon to be enabled + }); testWidgets('show topic visibility icon when followed', (tester) async { await setupMessageListPage(tester, From 000cc84e16d0f5ff64406d88f40a7aa813aa3bed Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 16 Jan 2025 15:07:49 -0500 Subject: [PATCH 021/290] api: Indicate support for handling empty topics Look for `allow_empty_topic_name` and `empty_topic_name` under "Feature level 334" in the API Changelog to verify the affected routes: https://zulip.com/api/changelog To keep the API bindings thin, instead of setting `allow_empty_topic_name` for the callers, we require the callers to pass the appropriate values instead. Fixes: #1250 --- lib/api/route/channels.dart | 6 ++++- lib/api/route/events.dart | 1 + lib/api/route/messages.dart | 9 +++++++ lib/model/autocomplete.dart | 4 ++- lib/model/message_list.dart | 2 ++ lib/widgets/actions.dart | 1 + test/api/route/messages_test.dart | 41 +++++++++++++++++++++++++++-- test/model/autocomplete_test.dart | 17 ++++++++++++ test/model/message_list_test.dart | 4 +++ test/widgets/action_sheet_test.dart | 12 +++++++++ test/widgets/message_list_test.dart | 2 ++ 11 files changed, 95 insertions(+), 4 deletions(-) diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart index bfa46f5ab8..8ae2076038 100644 --- a/lib/api/route/channels.dart +++ b/lib/api/route/channels.dart @@ -7,8 +7,12 @@ part 'channels.g.dart'; /// https://zulip.com/api/get-stream-topics Future getStreamTopics(ApiConnection connection, { required int streamId, + required bool allowEmptyTopicName, }) { - return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {}); + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); + return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', { + 'allow_empty_topic_name': allowEmptyTopicName, + }); } @JsonSerializable(fieldRename: FieldRename.snake) diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart index bbd7be5a0a..bd14521c74 100644 --- a/lib/api/route/events.dart +++ b/lib/api/route/events.dart @@ -18,6 +18,7 @@ Future registerQueue(ApiConnection connection) { 'user_avatar_url_field_optional': false, // TODO(#254): turn on 'stream_typing_notifications': true, 'user_settings_object': true, + 'empty_topic_name': true, }, }); } diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 449bf9fd80..ccafdbce45 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -16,6 +16,7 @@ part 'messages.g.dart'; Future getMessageCompat(ApiConnection connection, { required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, }) async { final useLegacyApi = connection.zulipFeatureLevel! < 120; if (useLegacyApi) { @@ -25,6 +26,7 @@ Future getMessageCompat(ApiConnection connection, { numBefore: 0, numAfter: 0, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, // Hard-code this param to `true`, as the new single-message API // effectively does: @@ -37,6 +39,7 @@ Future getMessageCompat(ApiConnection connection, { final response = await getMessage(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); return response.message; } on ZulipApiException catch (e) { @@ -57,10 +60,13 @@ Future getMessageCompat(ApiConnection connection, { Future getMessage(ApiConnection connection, { required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, }) { + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); assert(connection.zulipFeatureLevel! >= 120); return connection.get('getMessage', GetMessageResult.fromJson, 'messages/$messageId', { if (applyMarkdown != null) 'apply_markdown': applyMarkdown, + 'allow_empty_topic_name': allowEmptyTopicName, }); } @@ -89,8 +95,10 @@ Future getMessages(ApiConnection connection, { required int numAfter, bool? clientGravatar, bool? applyMarkdown, + required bool allowEmptyTopicName, // bool? useFirstUnreadAnchor // omitted because deprecated }) { + assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true'); return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', { 'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!), 'anchor': RawParameter(anchor.toJson()), @@ -99,6 +107,7 @@ Future getMessages(ApiConnection connection, { 'num_after': numAfter, if (clientGravatar != null) 'client_gravatar': clientGravatar, if (applyMarkdown != null) 'apply_markdown': applyMarkdown, + 'allow_empty_topic_name': allowEmptyTopicName, }); } diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 8fee7620dc..034199521d 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -904,7 +904,9 @@ class TopicAutocompleteView extends AutocompleteView _fetch() async { assert(!_isFetching); _isFetching = true; - final result = await getStreamTopics(store.connection, streamId: streamId); + final result = await getStreamTopics(store.connection, streamId: streamId, + allowEmptyTopicName: true, + ); _topics = result.topics.map((e) => e.name); _isFetching = false; return _startSearch(); diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 2e4c4fb600..275aa66363 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -495,6 +495,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { anchor: AnchorCode.newest, numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: true, ); if (this.generation > generation) return; _adjustNarrowForTopicPermalink(result.messages.firstOrNull); @@ -567,6 +568,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { includeAnchor: false, numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: true, ); } catch (e) { hasFetchError = true; diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index ad4557be08..61032d81e1 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -260,6 +260,7 @@ abstract final class ZulipAction { fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(context).connection, messageId: messageId, applyMarkdown: false, + allowEmptyTopicName: true, ); if (fetchedMessage == null) { errorMessage = zulipLocalizations.errorMessageDoesNotSeemToExist; diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index aff49bd8af..2a881d2145 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -20,10 +20,12 @@ void main() { required bool expectLegacy, required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, }) async { final result = await getMessageCompat(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); if (expectLegacy) { check(connection.lastRequest).isA() @@ -35,6 +37,7 @@ void main() { 'num_before': '0', 'num_after': '0', if (applyMarkdown != null) 'apply_markdown': applyMarkdown.toString(), + 'allow_empty_topic_name': allowEmptyTopicName.toString(), 'client_gravatar': 'true', }); } else { @@ -43,6 +46,7 @@ void main() { ..url.path.equals('/api/v1/messages/$messageId') ..url.queryParameters.deepEquals({ if (applyMarkdown != null) 'apply_markdown': applyMarkdown.toString(), + 'allow_empty_topic_name': allowEmptyTopicName.toString(), }); } return result; @@ -57,6 +61,7 @@ void main() { expectLegacy: false, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNotNull().jsonEquals(message); }); @@ -71,6 +76,7 @@ void main() { expectLegacy: false, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNull(); }); @@ -92,6 +98,7 @@ void main() { expectLegacy: true, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNotNull().jsonEquals(message); }); @@ -113,6 +120,7 @@ void main() { expectLegacy: true, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNull(); }); @@ -124,11 +132,13 @@ void main() { FakeApiConnection connection, { required int messageId, bool? applyMarkdown, + required bool allowEmptyTopicName, required Map expected, }) async { final result = await getMessage(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); check(connection.lastRequest).isA() ..method.equals('GET') @@ -145,7 +155,11 @@ void main() { await checkGetMessage(connection, messageId: 1, applyMarkdown: true, - expected: {'apply_markdown': 'true'}); + allowEmptyTopicName: true, + expected: { + 'apply_markdown': 'true', + 'allow_empty_topic_name': 'true', + }); }); }); @@ -155,7 +169,21 @@ void main() { await checkGetMessage(connection, messageId: 1, applyMarkdown: false, - expected: {'apply_markdown': 'false'}); + allowEmptyTopicName: true, + expected: { + 'apply_markdown': 'false', + 'allow_empty_topic_name': 'true', + }); + }); + }); + + test('allow empty topic name', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: fakeResult.toJson()); + await checkGetMessage(connection, + messageId: 1, + allowEmptyTopicName: true, + expected: {'allow_empty_topic_name': 'true'}); }); }); @@ -164,6 +192,7 @@ void main() { connection.prepare(json: fakeResult.toJson()); check(() => getMessage(connection, messageId: 1, + allowEmptyTopicName: true, )).throws(); }); }); @@ -255,12 +284,14 @@ void main() { required int numAfter, bool? clientGravatar, bool? applyMarkdown, + required bool allowEmptyTopicName, required Map expected, }) async { final result = await getMessages(connection, narrow: narrow, anchor: anchor, includeAnchor: includeAnchor, numBefore: numBefore, numAfter: numAfter, clientGravatar: clientGravatar, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); check(connection.lastRequest).isA() ..method.equals('GET') @@ -279,11 +310,13 @@ void main() { await checkGetMessages(connection, narrow: const CombinedFeedNarrow().apiEncode(), anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([]), 'anchor': 'newest', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); @@ -294,6 +327,7 @@ void main() { await checkGetMessages(connection, narrow: [ApiNarrowDm([123, 234])], anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([ {'operator': 'pm-with', 'operand': [123, 234]}, @@ -301,6 +335,7 @@ void main() { 'anchor': 'newest', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); @@ -312,11 +347,13 @@ void main() { narrow: const CombinedFeedNarrow().apiEncode(), anchor: const NumericAnchor(42), numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([]), 'anchor': '42', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index 16b92d98d4..cab073db48 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -19,6 +20,7 @@ import 'package:zulip/widgets/compose_box.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; +import '../stdlib_checks.dart'; import 'test_store.dart'; import 'autocomplete_checks.dart'; @@ -1026,6 +1028,21 @@ void main() { check(done).isTrue(); }); + test('TopicAutocompleteView getStreamTopics request', () async { + final store = eg.store(); + final connection = store.connection as FakeApiConnection; + + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: '')], + ).toJson()); + TopicAutocompleteView.init(store: store, streamId: 1000, + query: TopicAutocompleteQuery('foo')); + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/users/me/1000/topics') + ..url.queryParameters['allow_empty_topic_name'].equals('true'); + }); + group('TopicAutocompleteQuery.testTopic', () { final store = eg.store(); void doCheck(String rawQuery, String topic, bool expected) { diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 4aedea1d03..539dbeaba7 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -82,6 +82,7 @@ void main() { bool? includeAnchor, required int numBefore, required int numAfter, + required bool allowEmptyTopicName, }) { check(connection.lastRequest).isA() ..method.equals('GET') @@ -92,6 +93,7 @@ void main() { if (includeAnchor != null) 'include_anchor': includeAnchor.toString(), 'num_before': numBefore.toString(), 'num_after': numAfter.toString(), + 'allow_empty_topic_name': allowEmptyTopicName.toString(), }); } @@ -126,6 +128,7 @@ void main() { anchor: 'newest', numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: true, ); } @@ -238,6 +241,7 @@ void main() { includeAnchor: false, numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: true, ); }); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index deda8078e2..7d6a5205bb 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1155,6 +1155,18 @@ void main() { await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); check(findQuoteAndReplyButton(tester)).isNull(); }); + + testWidgets('handle empty topic', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, narrow: TopicNarrow.ofMessage(message)); + + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + check(connection.lastRequest).isA() + .url.queryParameters['allow_empty_topic_name'].equals('true'); + await tester.pump(Duration.zero); + }); }); group('MarkAsUnread', () { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 816cde5948..f4de7b54ae 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -331,6 +331,7 @@ void main() { 'anchor': AnchorCode.newest.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), 'num_after': '0', + 'allow_empty_topic_name': 'true', }); }); @@ -363,6 +364,7 @@ void main() { 'anchor': AnchorCode.newest.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), 'num_after': '0', + 'allow_empty_topic_name': 'true', }); }); }); From 373e23a69f8be0339bccb315bc34ba6048cffb77 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 21:31:02 -0400 Subject: [PATCH 022/290] api [nfc]: Add TopicName.processLikeServer The point of this helper is to replicate what a topic sent from the client will become, after being processed by the server. This important when trying to create a local copy of a stream message, whose topic can get translated when it's delivered by the server. --- lib/api/model/model.dart | 56 ++++++++++++++++++++++++++++++++++ lib/api/route/messages.dart | 9 ------ test/api/model/model_test.dart | 25 +++++++++++++++ 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 6b2cb4dd02..a2874c4c44 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -550,6 +550,15 @@ String? tryParseEmojiCodeToUnicode(String emojiCode) { } } +/// The topic servers understand to mean "there is no topic". +/// +/// This should match +/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 +/// or similar logic at the latest `main`. +// This is hardcoded in the server, and therefore untranslated; that's +// zulip/zulip#3639. +const String kNoTopicTopic = '(no topic)'; + /// The name of a Zulip topic. // TODO(dart): Can we forbid calling Object members on this extension type? // (The lack of "implements Object" ought to do that, but doesn't.) @@ -600,6 +609,53 @@ extension type const TopicName(String _value) { /// using [canonicalize]. bool isSameAs(TopicName other) => canonicalize() == other.canonicalize(); + /// Process this topic to match how it would appear on a message object from + /// the server. + /// + /// This returns the [TopicName] the server would be predicted to include + /// in a message object resulting from sending to this [TopicName] + /// in a [sendMessage] request. + /// + /// This [TopicName] is required to have no leading or trailing whitespace. + /// + /// For a client that supports empty topics, when FL>=334, the server converts + /// `store.realmEmptyTopicDisplayName` to an empty string; when FL>=370, + /// the server converts "(no topic)" to an empty string as well. + /// + /// See API docs: + /// https://zulip.com/api/send-message#parameter-topic + TopicName processLikeServer({ + required int zulipFeatureLevel, + required String? realmEmptyTopicDisplayName, + }) { + assert(_value.trim() == _value); + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 334) { + // From the API docs: + // > Before Zulip 10.0 (feature level 334), empty string was not a valid + // > topic name for channel messages. + assert(_value.isNotEmpty); + return this; + } + + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 370 && _value == kNoTopicTopic) { + // From the API docs: + // > Before Zulip 10.0 (feature level 370), "(no topic)" was not + // > interpreted as an empty string. + return TopicName(kNoTopicTopic); + } + + if (_value == kNoTopicTopic || _value == realmEmptyTopicDisplayName) { + // From the API docs: + // > When "(no topic)" or the value of realm_empty_topic_display_name + // > found in the POST /register response is used for [topic], + // > it is interpreted as an empty string. + return TopicName(''); + } + return TopicName(_value); + } + TopicName.fromJson(this._value); String toJson() => apiName; diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index ccafdbce45..f55e630585 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -178,15 +178,6 @@ const int kMaxTopicLengthCodePoints = 60; // https://zulip.com/api/send-message#parameter-content const int kMaxMessageLengthCodePoints = 10000; -/// The topic servers understand to mean "there is no topic". -/// -/// This should match -/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 -/// or similar logic at the latest `main`. -// This is hardcoded in the server, and therefore untranslated; that's -// zulip/zulip#3639. -const String kNoTopicTopic = '(no topic)'; - /// https://zulip.com/api/send-message Future sendMessage( ApiConnection connection, { diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index b1552deb5b..6012f29ead 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -161,6 +161,31 @@ void main() { doCheck(eg.t('✔ a'), eg.t('✔ b'), false); }); + + test('processLikeServer', () { + final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName; + void doCheck(TopicName topic, TopicName expected, int zulipFeatureLevel) { + check(topic.processLikeServer( + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: emptyTopicDisplayName), + ).equals(expected); + } + + check(() => eg.t('').processLikeServer( + zulipFeatureLevel: 333, + realmEmptyTopicDisplayName: emptyTopicDisplayName), + ).throws(); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 333); + doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 333); + doCheck(eg.t('other topic'), eg.t('other topic'), 333); + + doCheck(eg.t(''), eg.t(''), 334); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 334); + doCheck(eg.t(emptyTopicDisplayName), eg.t(''), 334); + doCheck(eg.t('other topic'), eg.t('other topic'), 334); + + doCheck(eg.t('(no topic)'), eg.t(''), 370); + }); }); group('DmMessage', () { From b982a781dd962c818ab1bc88240d6a891c9feb33 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 21:33:41 -0400 Subject: [PATCH 023/290] store [nfc]: Move zulip{FeatureLevel,Version} to PerAccountStoreBase --- lib/model/store.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 300b7dc22f..f7a4e0ca4e 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -385,6 +385,12 @@ abstract class PerAccountStoreBase { /// This returns null if [reference] fails to parse as a URL. Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference); + /// Always equal to `connection.zulipFeatureLevel` + /// and `account.zulipFeatureLevel`. + int get zulipFeatureLevel => connection.zulipFeatureLevel!; + + String get zulipVersion => account.zulipVersion; + //////////////////////////////// // Data attached to the self-account on the realm. @@ -558,11 +564,6 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor //////////////////////////////// // Data attached to the realm or the server. - /// Always equal to `connection.zulipFeatureLevel` - /// and `account.zulipFeatureLevel`. - int get zulipFeatureLevel => connection.zulipFeatureLevel!; - - String get zulipVersion => account.zulipVersion; final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting final bool realmMandatoryTopics; // TODO(#668): update this realm setting /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. From fa5dfd568865f4991aae1dc8d8699501b2647d84 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 00:04:54 -0400 Subject: [PATCH 024/290] test [nfc]: Add utcTimestamp --- test/example_data.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/example_data.dart b/test/example_data.dart index 7b517aca79..9577ef0e73 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -69,6 +69,20 @@ ZulipApiException apiExceptionUnauthorized({String routeName = 'someRoute'}) { data: {}, message: 'Invalid API key'); } +//////////////////////////////////////////////////////////////// +// Time values. +// + +final timeInPast = DateTime.utc(2025, 4, 1, 8, 30, 0); + +/// The UNIX timestamp, in UTC seconds. +/// +/// This is the commonly used format in the Zulip API for timestamps. +int utcTimestamp([DateTime? dateTime]) { + dateTime ??= timeInPast; + return dateTime.toUtc().millisecondsSinceEpoch ~/ 1000; +} + //////////////////////////////////////////////////////////////// // Realm-wide (or server-wide) metadata. // From 0c03009977b548bfe273930f2b1dfa68ebcd46ec Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 13:03:37 -0400 Subject: [PATCH 025/290] binding [nfc]: Add utcNow This will be the same as `DateTime.timestamp()` in live code (therefore the NFC). For testing, utcNow uses a clock instance that can be controlled by FakeAsync. We could have made call sites of `DateTime.now()` use it too, but those for now don't need it for testing. --- lib/model/binding.dart | 8 ++++++++ test/model/binding.dart | 3 +++ 2 files changed, 11 insertions(+) diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 198e0ae119..fb3add46da 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -120,6 +120,11 @@ abstract class ZulipBinding { /// This wraps [url_launcher.closeInAppWebView]. Future closeInAppWebView(); + /// Provides access to the current UTC date and time. + /// + /// Outside tests, this just calls [DateTime.timestamp]. + DateTime utcNow(); + /// Provides access to a new stopwatch. /// /// Outside tests, this just calls the [Stopwatch] constructor. @@ -383,6 +388,9 @@ class LiveZulipBinding extends ZulipBinding { return url_launcher.closeInAppWebView(); } + @override + DateTime utcNow() => DateTime.timestamp(); + @override Stopwatch stopwatch() => Stopwatch(); diff --git a/test/model/binding.dart b/test/model/binding.dart index 31f5738ddf..2c70b68826 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -241,6 +241,9 @@ class TestZulipBinding extends ZulipBinding { _closeInAppWebViewCallCount++; } + @override + DateTime utcNow() => clock.now().toUtc(); + @override Stopwatch stopwatch() => clock.stopwatch(); From 86e2397d0a19f669358a9fb0f8927b5eca19291c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 20:05:46 -0700 Subject: [PATCH 026/290] msglist [nfc]: Assert the model is non-null a bit more This method is only called from callbacks which can only get invoked after the widget has built; and the build method already requires `model` to have been initialized. This is therefore NFC. --- 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 98beda3205..dffeec5143 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -507,7 +507,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat // but makes things a bit more complicated to reason about. // The cause seems to be that this gets called again with maxScrollExtent // still not yet updated to account for the newly-added messages. - model?.fetchOlder(); + model!.fetchOlder(); } } From a6e0efde2daa69f9ce965be9ff3f3809c3dff5ef Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 20:09:02 -0700 Subject: [PATCH 027/290] msglist [nfc]: Simplify a bit by centralizing `model!` null-assertion --- lib/widgets/message_list.dart | 36 ++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index dffeec5143..82be2ffe18 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -448,8 +448,11 @@ class MessageList extends StatefulWidget { } class _MessageListState extends State with PerAccountStoreAwareStateMixin { - MessageListView? model; + MessageListView get model => _model!; + MessageListView? _model; + final MessageListScrollController scrollController = MessageListScrollController(); + final ValueNotifier _scrollToBottomVisible = ValueNotifier(false); @override @@ -460,32 +463,32 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages - model?.dispose(); + _model?.dispose(); _initModel(PerAccountStoreWidget.of(context)); } @override void dispose() { - model?.dispose(); + _model?.dispose(); scrollController.dispose(); _scrollToBottomVisible.dispose(); super.dispose(); } void _initModel(PerAccountStore store) { - model = MessageListView.init(store: store, narrow: widget.narrow); - model!.addListener(_modelChanged); - model!.fetchInitial(); + _model = MessageListView.init(store: store, narrow: widget.narrow); + model.addListener(_modelChanged); + model.fetchInitial(); } void _modelChanged() { - if (model!.narrow != widget.narrow) { + if (model.narrow != widget.narrow) { // 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); + widget.onNarrowChanged(model.narrow); } setState(() { // The actual state lives in the [MessageListView] model. @@ -507,7 +510,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat // but makes things a bit more complicated to reason about. // The cause seems to be that this gets called again with maxScrollExtent // still not yet updated to account for the newly-added messages. - model!.fetchOlder(); + model.fetchOlder(); } } @@ -528,8 +531,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { - assert(model != null); - if (!model!.fetched) return const Center(child: CircularProgressIndicator()); + if (!model.fetched) return const Center(child: CircularProgressIndicator()); // Pad the left and right insets, for small devices in landscape. return SafeArea( @@ -571,9 +573,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat // The list has two slivers: a top sliver growing upward, // and a bottom sliver growing downward. - // Each sliver has some of the items from `model!.items`. + // Each sliver has some of the items from `model.items`. const maxBottomItems = 1; - final totalItems = model!.items.length; + final totalItems = model.items.length; final bottomItems = totalItems <= maxBottomItems ? totalItems : maxBottomItems; final topItems = totalItems - bottomItems; @@ -599,7 +601,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat // and will not trigger this callback. findChildIndexCallback: (Key key) { final messageId = (key as ValueKey).value; - final itemIndex = model!.findItemWithMessageId(messageId); + final itemIndex = model.findItemWithMessageId(messageId); if (itemIndex == -1) return null; final childIndex = totalItems - 1 - (itemIndex + bottomItems); if (childIndex < 0) return null; @@ -608,7 +610,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat childCount: topItems, (context, childIndex) { final itemIndex = totalItems - 1 - (childIndex + bottomItems); - final data = model!.items[itemIndex]; + final data = model.items[itemIndex]; final item = _buildItem(zulipLocalizations, data); return item; })); @@ -637,7 +639,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat // and will not trigger this callback. findChildIndexCallback: (Key key) { final messageId = (key as ValueKey).value; - final itemIndex = model!.findItemWithMessageId(messageId); + final itemIndex = model.findItemWithMessageId(messageId); if (itemIndex == -1) return null; final childIndex = itemIndex - topItems; if (childIndex < 0) return null; @@ -654,7 +656,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat if (childIndex == bottomItems) return TypingStatusWidget(narrow: widget.narrow); final itemIndex = topItems + childIndex; - final data = model!.items[itemIndex]; + final data = model.items[itemIndex]; return _buildItem(zulipLocalizations, data); })); From 4b69adfae505b5b67a36da12ba9d0a5315b2c229 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 8 May 2025 15:02:20 -0700 Subject: [PATCH 028/290] msglist [nfc]: Make start/loading indicators their own widgets --- lib/widgets/message_list.dart | 42 +++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 82be2ffe18..01b973f9ca 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -569,7 +569,6 @@ class _MessageListState extends State with PerAccountStoreAwareStat Widget _buildListView(BuildContext context) { const centerSliverKey = ValueKey('center sliver'); - final zulipLocalizations = ZulipLocalizations.of(context); // The list has two slivers: a top sliver growing upward, // and a bottom sliver growing downward. @@ -611,7 +610,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat (context, childIndex) { final itemIndex = totalItems - 1 - (childIndex + bottomItems); final data = model.items[itemIndex]; - final item = _buildItem(zulipLocalizations, data); + final item = _buildItem(data); return item; })); @@ -657,7 +656,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat final itemIndex = topItems + childIndex; final data = model.items[itemIndex]; - return _buildItem(zulipLocalizations, data); + return _buildItem(data); })); if (!ComposeBox.hasComposeBox(widget.narrow)) { @@ -689,18 +688,12 @@ class _MessageListState extends State with PerAccountStoreAwareStat ]); } - Widget _buildItem(ZulipLocalizations zulipLocalizations, MessageListItem data) { + Widget _buildItem(MessageListItem data) { switch (data) { case MessageListHistoryStartItem(): - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Text(zulipLocalizations.noEarlierMessages))); // TODO use an icon + return const _MessageListHistoryStart(); case MessageListLoadingItem(): - return const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: CircularProgressIndicator())); // TODO perhaps a different indicator + return const _MessageListLoadingMore(); case MessageListRecipientHeaderItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); return StickyHeaderItem(allowOverflow: true, @@ -721,6 +714,31 @@ class _MessageListState extends State with PerAccountStoreAwareStat } } +class _MessageListHistoryStart extends StatelessWidget { + const _MessageListHistoryStart(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Text(zulipLocalizations.noEarlierMessages))); // TODO use an icon + } +} + +class _MessageListLoadingMore extends StatelessWidget { + const _MessageListLoadingMore(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: CircularProgressIndicator())); // TODO perhaps a different indicator + } +} + class ScrollToBottomButton extends StatelessWidget { const ScrollToBottomButton({super.key, required this.scrollController, required this.visible}); From c5e695738130adb0924306db84ec0b320fe573bf Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 8 May 2025 15:18:33 -0700 Subject: [PATCH 029/290] msglist [nfc]: Handle start markers within widget code, not model The view-model already exposes flags that express all the same information. By leaving these markers out of the list of items, we can save ourselves a bunch of stateful updates. --- lib/model/message_list.dart | 49 ------------------------------- lib/widgets/message_list.dart | 21 +++++++++---- test/model/message_list_test.dart | 11 +------ 3 files changed, 17 insertions(+), 64 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 275aa66363..2f8777d3ed 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -63,21 +63,6 @@ class MessageListMessageItem extends MessageListMessageBaseItem { }); } -/// Indicates the app is loading more messages at the top. -// TODO(#80): or loading at the bottom, by adding a [MessageListDirection.newer] -class MessageListLoadingItem extends MessageListItem { - final MessageListDirection direction; - - const MessageListLoadingItem(this.direction); -} - -enum MessageListDirection { older } - -/// Indicates we've reached the oldest message in the narrow. -class MessageListHistoryStartItem extends MessageListItem { - const MessageListHistoryStartItem(); -} - /// The sequence of messages in a message list, and how to display them. /// /// This comprises much of the guts of [MessageListView]. @@ -161,11 +146,6 @@ mixin _MessageSequence { static int _compareItemToMessageId(MessageListItem item, int messageId) { switch (item) { - case MessageListHistoryStartItem(): return -1; - case MessageListLoadingItem(): - switch (item.direction) { - case MessageListDirection.older: return -1; - } case MessageListRecipientHeaderItem(:var message): case MessageListDateSeparatorItem(:var message): if (message.id == null) return 1; // TODO(#1441): test @@ -328,37 +308,12 @@ mixin _MessageSequence { showSender: !canShareSender, isLastInBlock: true)); } - /// Update [items] to include markers at start and end as appropriate. - void _updateEndMarkers() { - assert(fetched); - assert(!(fetchingOlder && fetchOlderCoolingDown)); - final effectiveFetchingOlder = fetchingOlder || fetchOlderCoolingDown; - assert(!(effectiveFetchingOlder && haveOldest)); - final startMarker = switch ((effectiveFetchingOlder, haveOldest)) { - (true, _) => const MessageListLoadingItem(MessageListDirection.older), - (_, true) => const MessageListHistoryStartItem(), - (_, _) => null, - }; - final hasStartMarker = switch (items.firstOrNull) { - MessageListLoadingItem() => true, - MessageListHistoryStartItem() => true, - _ => false, - }; - switch ((startMarker != null, hasStartMarker)) { - case (true, true): items[0] = startMarker!; - case (true, _ ): items.addFirst(startMarker!); - case (_, true): items.removeFirst(); - case (_, _ ): break; - } - } - /// Recompute [items] from scratch, based on [messages], [contents], and flags. void _reprocessAll() { items.clear(); for (var i = 0; i < messages.length; i++) { _processMessage(i); } - _updateEndMarkers(); } } @@ -508,7 +463,6 @@ class MessageListView with ChangeNotifier, _MessageSequence { } _fetched = true; _haveOldest = result.foundOldest; - _updateEndMarkers(); notifyListeners(); } @@ -555,7 +509,6 @@ class MessageListView with ChangeNotifier, _MessageSequence { || (narrow as TopicNarrow).with_ == null); assert(messages.isNotEmpty); _fetchingOlder = true; - _updateEndMarkers(); notifyListeners(); final generation = this.generation; bool hasFetchError = false; @@ -601,13 +554,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { .wait().then((_) { if (this.generation != generation) return; _fetchOlderCoolingDown = false; - _updateEndMarkers(); notifyListeners(); })); } else { _fetchOlderCooldownBackoffMachine = null; } - _updateEndMarkers(); notifyListeners(); } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 01b973f9ca..3e8850f5cc 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -606,8 +606,10 @@ class _MessageListState extends State with PerAccountStoreAwareStat if (childIndex < 0) return null; return childIndex; }, - childCount: topItems, + childCount: topItems + 1, (context, childIndex) { + if (childIndex == topItems) return _buildStartCap(); + final itemIndex = totalItems - 1 - (childIndex + bottomItems); final data = model.items[itemIndex]; final item = _buildItem(data); @@ -688,12 +690,21 @@ class _MessageListState extends State with PerAccountStoreAwareStat ]); } + Widget _buildStartCap() { + // These assertions are invariants of [MessageListView]. + assert(!(model.fetchingOlder && model.fetchOlderCoolingDown)); + final effectiveFetchingOlder = + model.fetchingOlder || model.fetchOlderCoolingDown; + assert(!(model.haveOldest && effectiveFetchingOlder)); + return switch ((effectiveFetchingOlder, model.haveOldest)) { + (true, _) => const _MessageListLoadingMore(), + (_, true) => const _MessageListHistoryStart(), + (_, _) => const SizedBox.shrink(), + }; + } + Widget _buildItem(MessageListItem data) { switch (data) { - case MessageListHistoryStartItem(): - return const _MessageListHistoryStart(); - case MessageListLoadingItem(): - return const _MessageListLoadingMore(); case MessageListRecipientHeaderItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); return StickyHeaderItem(allowOverflow: true, diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 539dbeaba7..bb85556ae9 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1791,7 +1791,6 @@ void main() { // We check showSender has the right values in [checkInvariants], // but to make this test explicit: check(model.items).deepEquals()>[ - (it) => it.isA(), (it) => it.isA(), (it) => it.isA().showSender.isTrue(), (it) => it.isA().showSender.isFalse(), @@ -1964,12 +1963,6 @@ void checkInvariants(MessageListView model) { } int i = 0; - if (model.haveOldest) { - check(model.items[i++]).isA(); - } - if (model.fetchingOlder || model.fetchOlderCoolingDown) { - check(model.items[i++]).isA(); - } for (int j = 0; j < model.messages.length; j++) { bool forcedShowSender = false; if (j == 0 @@ -1991,9 +1984,7 @@ void checkInvariants(MessageListView model) { i == model.items.length || switch (model.items[i]) { MessageListMessageItem() || MessageListDateSeparatorItem() => false, - MessageListRecipientHeaderItem() - || MessageListHistoryStartItem() - || MessageListLoadingItem() => true, + MessageListRecipientHeaderItem() => true, }); } check(model.items).length.equals(i); From 710ebd9bbbd9398ea37564398c42ae9a4d0260e2 Mon Sep 17 00:00:00 2001 From: chimnayajith Date: Mon, 28 Apr 2025 15:41:26 +0530 Subject: [PATCH 030/290] nixos: Update to use libepoxy, jdk17, nodejs - Renamed epoxy to libepoxy to align with current Nixpkgs. - Upgraded jdk11 to jdk17 due to Android Gradle's requirements. - Added nodejs for compatibility with the icon tool using npm. Tested on NixOS version 24.11 --- shell.nix | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shell.nix b/shell.nix index 7404f88f6c..e286f2d4c1 100644 --- a/shell.nix +++ b/shell.nix @@ -12,7 +12,7 @@ mkShell { gtk3 # Curiously `nix-env -i` can't handle this one adequately. # But `nix-shell` on this shell.nix does fine. pcre - epoxy + libepoxy # This group all seem not strictly necessary -- commands like # `flutter run -d linux` seem to *work* fine without them, but @@ -34,9 +34,11 @@ mkShell { xorg.libXtst.out pcre2.dev - jdk11 + jdk17 android-studio android-tools + + nodejs ]; LD_LIBRARY_PATH = lib.makeLibraryPath [ From 4abc86303f5e5a2d99b093752faf811bfd961605 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 6 May 2025 21:34:19 +0530 Subject: [PATCH 031/290] ios build: Take auto updates to Podfile.lock This commit is a result of running `flutter run`. The change to "PODFILE CHECKSUM" is probably a side-effect of the change in 37dc9eaa4. And the change to "Flutter" entry in "SPEC CHECKSUMS" is probably a result of the previous Flutter upgrade in 7dfad7c42. --- ios/Podfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c0db288bed..121a9c810c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -227,7 +227,7 @@ SPEC CHECKSUMS: FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a @@ -245,6 +245,6 @@ SPEC CHECKSUMS: video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 -PODFILE CHECKSUM: 7ed5116924b3be7e8fb75f7aada61e057028f5c7 +PODFILE CHECKSUM: 66b7725a92b85e7acc28f0ced0cdacebf18e1997 COCOAPODS: 1.16.2 From 4524021d43207d869d56960b691a9f5881c62e77 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 5 May 2025 21:47:09 +0530 Subject: [PATCH 032/290] deps: Upgrade Flutter to 3.33.0-1.0.pre.44 --- macos/Podfile.lock | 2 +- pubspec.lock | 4 ++-- pubspec.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index dab9b53101..8e1ae31f3a 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -182,7 +182,7 @@ SPEC CHECKSUMS: FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 diff --git a/pubspec.lock b/pubspec.lock index a1a7177bc2..d97e416efe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1355,5 +1355,5 @@ packages: source: path version: "0.0.1" sdks: - dart: ">=3.9.0-63.0.dev <4.0.0" - flutter: ">=3.32.0-1.0.pre.332" + dart: ">=3.9.0-114.0.dev <4.0.0" + flutter: ">=3.33.0-1.0.pre.44" diff --git a/pubspec.yaml b/pubspec.yaml index a1c9f6dd2d..76b2e2bd9b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,8 +14,8 @@ environment: # We use a recent version of Flutter from its main channel, and # the corresponding recent version of the Dart SDK. # Feel free to update these regularly; see README.md for instructions. - sdk: '>=3.9.0-63.0.dev <4.0.0' - flutter: '>=3.32.0-1.0.pre.332' # adae8bbdbaed53ef305726fcfe811b2351d73a1a + sdk: '>=3.9.0-114.0.dev <4.0.0' + flutter: '>=3.33.0-1.0.pre.44' # 358b0726882869cd917e1e2dc07b6c298e6c2992 # To update dependencies, see instructions in README.md. dependencies: From f8cb02f089f03f17992cf1207782458eba63dd50 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 6 May 2025 22:03:45 +0530 Subject: [PATCH 033/290] deps: Update share_plus to 11.0.0, from 10.1.4 Changelog: https://pub.dev/packages/share_plus/changelog#1100 Just one change, a fairly large refactor: https://github.com/fluttercommunity/plus_plugins/commit/0a19d4601 So, this commit also includes the switch to use the newer API. --- lib/widgets/action_sheet.dart | 3 ++- pubspec.lock | 8 ++++---- pubspec.yaml | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6aa7239a0c..4beea3db66 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -939,7 +939,8 @@ class ShareButton extends MessageActionSheetMenuItemButton { // https://pub.dev/packages/share_plus#ipad // Perhaps a wart in the API; discussion: // https://github.com/zulip/zulip-flutter/pull/12#discussion_r1130146231 - final result = await Share.share(rawContent); + final result = + await SharePlus.instance.share(ShareParams(text: rawContent)); switch (result.status) { // The plugin isn't very helpful: "The status can not be determined". diff --git a/pubspec.lock b/pubspec.lock index d97e416efe..40de02fba0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -906,18 +906,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 url: "https://pub.dev" source: hosted - version: "10.1.4" + version: "11.0.0" share_plus_platform_interface: dependency: "direct main" description: name: share_plus_platform_interface - sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.0.0" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 76b2e2bd9b..d9fa354b16 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,8 +55,8 @@ dependencies: package_info_plus: ^8.0.0 path: ^1.8.3 path_provider: ^2.0.13 - share_plus: ^10.1.3 - share_plus_platform_interface: ^5.0.2 + share_plus: ^11.0.0 + share_plus_platform_interface: ^6.0.0 sqlite3: ^2.4.0 sqlite3_flutter_libs: ^0.5.13 url_launcher: ^6.1.11 From 984aa17241c8d57b2a07eddc8500b4e79158274d Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 13 May 2025 21:58:39 +0530 Subject: [PATCH 034/290] deps: Upgrade packages within constraints (tools/upgrade pub) --- ios/Podfile.lock | 20 +++++----- macos/Podfile.lock | 18 ++++----- pubspec.lock | 92 +++++++++++++++++++++++----------------------- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 121a9c810c..1bd78a4b7f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -73,28 +73,28 @@ PODS: - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.0.2)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.0.2) - - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - image_picker_ios (0.0.1): @@ -229,7 +229,7 @@ SPEC CHECKSUMS: FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 @@ -243,7 +243,7 @@ SPEC CHECKSUMS: SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b - wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: 66b7725a92b85e7acc28f0ced0cdacebf18e1997 diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 8e1ae31f3a..bb5fd1a927 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -44,28 +44,28 @@ PODS: - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.0.2)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.0.2) - - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - nanopb (3.30910.0): @@ -184,7 +184,7 @@ SPEC CHECKSUMS: FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 diff --git a/pubspec.lock b/pubspec.lock index 40de02fba0..4784adf19a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f url: "https://pub.dev" source: hosted - version: "80.0.0" + version: "82.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.4.5" app_settings: dependency: "direct main" description: @@ -214,10 +214,10 @@ packages: dependency: transitive description: name: coverage - sha256: "9086475ef2da7102a0c0a4e37e1e30707e7fb7b6d28c209f559a9c5f8ce42016" + sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.13.1" cross_file: dependency: transitive description: @@ -262,10 +262,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "306b78788d1bb569edb7c55d622953c2414ca12445b41c9117963e03afc5c513" + sha256: "0c6396126421b590089447154c5f98a5de423b70cfb15b1578fd018843ee6f53" url: "https://pub.dev" source: hosted - version: "11.3.3" + version: "11.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -278,18 +278,18 @@ packages: dependency: "direct main" description: name: drift - sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5" + sha256: b584ddeb2b74436735dd2cf746d2d021e19a9a6770f409212fd5cbc2814ada85 url: "https://pub.dev" source: hosted - version: "2.26.0" + version: "2.26.1" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588" + sha256: "54dc207c6e4662741f60e5752678df183957ab907754ffab0372a7082f6d2816" url: "https://pub.dev" source: hosted - version: "2.26.0" + version: "2.26.1" fake_async: dependency: "direct dev" description: @@ -318,10 +318,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "8986dec4581b4bcd4b6df5d75a2ea0bede3db802f500635d05fa8be298f9467f" + sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "10.1.9" file_selector_linux: dependency: transitive description: @@ -454,10 +454,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.0.28" flutter_test: dependency: "direct dev" description: flutter @@ -501,18 +501,18 @@ packages: dependency: "direct main" description: name: html - sha256: "9475be233c437f0e3637af55e7702cbbe5c23a68bd56e8a5fa2d426297b7c6c8" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.5+1" + version: "0.15.6" http: dependency: "direct main" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" http_multi_server: dependency: transitive description: @@ -541,10 +541,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9" + sha256: "317a5d961cec5b34e777b9252393f2afbd23084aa6e60fcf601dcf6341b9ebeb" url: "https://pub.dev" source: hosted - version: "0.8.12+22" + version: "0.8.12+23" image_picker_for_web: dependency: transitive description: @@ -642,10 +642,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: "81f04dee10969f89f604e1249382d46b97a1ccad53872875369622b5bfc9e58a" + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.9.4" + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -786,10 +786,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.16" + version: "2.2.17" path_provider_foundation: dependency: transitive description: @@ -1127,10 +1127,10 @@ packages: dependency: "direct main" description: name: url_launcher_android - sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" url: "https://pub.dev" source: hosted - version: "6.3.15" + version: "6.3.16" url_launcher_ios: dependency: transitive description: @@ -1167,10 +1167,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -1207,18 +1207,18 @@ packages: dependency: transitive description: name: video_player_android - sha256: ae7d4f1b41e3ac6d24dd9b9d5d6831b52d74a61bdd90a7a6262a33d8bb97c29a + sha256: "1f4e8e0e02403452d699ef7cf73fe9936fac8f6f0605303d111d71acb375d1bc" url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.8.3" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "84b4752745eeccb6e75865c9aab39b3d28eb27ba5726d352d45db8297fbd75bc" + sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.7.1" video_player_platform_interface: dependency: "direct dev" description: @@ -1231,10 +1231,10 @@ packages: dependency: transitive description: name: video_player_web - sha256: "3ef40ea6d72434edbfdba4624b90fd3a80a0740d260667d91e7ecd2d79e13476" + sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" vm_service: dependency: transitive description: @@ -1247,18 +1247,18 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: b90fbcc8d7bdf3b883ea9706d9d76b9978cb1dfa4351fcc8014d6ec31a493354 + sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 url: "https://pub.dev" source: hosted - version: "1.2.11" + version: "1.3.2" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a" + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" watcher: dependency: transitive description: @@ -1279,18 +1279,18 @@ packages: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webdriver: dependency: transitive description: @@ -1311,10 +1311,10 @@ packages: dependency: transitive description: name: win32 - sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" url: "https://pub.dev" source: hosted - version: "5.12.0" + version: "5.13.0" win32_registry: dependency: transitive description: From 4b7e76c636e595e8c1cea079ec303a04d320d2d5 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 7 May 2025 19:49:36 +0530 Subject: [PATCH 035/290] deps android: Upgrade Gradle to 8.14, from 8.13 Changelogs: https://docs.gradle.org/8.14/release-notes.html Update added support for Java 24. --- android/gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 479cd23287..0af2956cea 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -6,7 +6,7 @@ # the wrapper is the one from the new Gradle too.) distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 9689dce8bb534fc921cd4902669a001a379be4ae Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 7 May 2025 20:02:37 +0530 Subject: [PATCH 036/290] deps android: Upgrade Android Gradle Plugin to 8.10.0, from 8.9.1 Release notes: https://developer.android.com/build/releases/past-releases/agp-8-9-0-release-notes#android-gradle-plugin-8.9.2 https://developer.android.com/build/releases/gradle-plugin (for 8.10.0, for now) Changes mostly seem to be bug fixes to various components. One notable change is that AGP version 8.10 now requires Android Studio Meerkat Feature Drop (2024.3.2). --- android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index e9d14e8cb1..bf7487203d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -6,7 +6,7 @@ android.enableJetifier=true # Defining them here makes them available both in # settings.gradle and in the build.gradle files. -agpVersion=8.9.1 +agpVersion=8.10.0 # Generally update this to the version found in recent releases # of Android Studio, as listed in this table: From 131a4e9e6431f68ec042511ff8d52c3ec0365ad8 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 13 May 2025 22:48:45 -0700 Subject: [PATCH 037/290] compose_box test: Use full MessageListPage in compose box widget tests --- test/widgets/compose_box_test.dart | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index f979c687de..3ee28a053e 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -20,6 +20,7 @@ import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/theme.dart'; @@ -69,15 +70,12 @@ void main() { connection = store.connection as FakeApiConnection; + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, - child: Column( - // This positions the compose box at the bottom of the screen, - // simulating the layout of the message list page. - children: [ - const Expanded(child: SizedBox.expand()), - ComposeBox(narrow: narrow), - ]))); + child: MessageListPage(initNarrow: narrow))); await tester.pumpAndSettle(); + connection.takeRequests(); controller = tester.state(find.byType(ComposeBox)).controller; } @@ -1334,6 +1332,14 @@ void main() { await enterContent(tester, 'some content'); checkContentInputValue(tester, 'some content'); + // Encache a new connection; prepare it for the message-list fetch + final newConnection = (testBinding.globalStore + ..clearCachedApiConnections() + ..useCachedApiConnections = true) + .apiConnectionFromAccount(store.account) as FakeApiConnection; + newConnection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + store.updateMachine! ..debugPauseLoop() ..poll() @@ -1341,6 +1347,7 @@ void main() { eg.apiExceptionBadEventQueueId(queueId: store.queueId)) ..debugAdvanceLoop(); await tester.pump(); + await tester.pump(Duration.zero); final newStore = testBinding.globalStore.perAccountSync(store.accountId)!; check(newStore) From 9e27a118f1cf53830acfa2fee6f06b33856b104c Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 1 May 2025 23:34:53 -0700 Subject: [PATCH 038/290] msglist [nfc]: s/localizations/zulipLocalizations/ --- lib/widgets/message_list.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 3e8850f5cc..8c280eac1c 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -817,16 +817,16 @@ class _TypingStatusWidgetState extends State with PerAccount if (narrow is! SendableNarrow) return const SizedBox(); final store = PerAccountStoreWidget.of(context); - final localizations = ZulipLocalizations.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final typistIds = model!.typistIdsInNarrow(narrow); if (typistIds.isEmpty) return const SizedBox(); final text = switch (typistIds.length) { - 1 => localizations.onePersonTyping( + 1 => zulipLocalizations.onePersonTyping( store.userDisplayName(typistIds.first)), - 2 => localizations.twoPeopleTyping( + 2 => zulipLocalizations.twoPeopleTyping( store.userDisplayName(typistIds.first), store.userDisplayName(typistIds.last)), - _ => localizations.manyPeopleTyping, + _ => zulipLocalizations.manyPeopleTyping, }; return Padding( @@ -1452,13 +1452,13 @@ class MessageWithPossibleSender extends StatelessWidget { final designVariables = DesignVariables.of(context); final message = item.message; - final localizations = ZulipLocalizations.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); String? editStateText; switch (message.editState) { case MessageEditState.edited: - editStateText = localizations.messageIsEditedLabel; + editStateText = zulipLocalizations.messageIsEditedLabel; case MessageEditState.moved: - editStateText = localizations.messageIsMovedLabel; + editStateText = zulipLocalizations.messageIsMovedLabel; case MessageEditState.none: } From bf38693bc094da7b1c002b6c452f57a7352e2096 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 5 May 2025 12:48:47 -0700 Subject: [PATCH 039/290] compose_box test [nfc]: Pull out sendButtonFinder helper --- test/widgets/compose_box_test.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 3ee28a053e..24c4d403b3 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -115,9 +115,11 @@ void main() { .controller.isNotNull().value.text.equals(expected); } + final sendButtonFinder = find.byIcon(ZulipIcons.send); + Future tapSendButton(WidgetTester tester) async { connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); } @@ -690,7 +692,7 @@ void main() { connection.prepare(json: {}); connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); final requests = connection.takeRequests(); checkSetTypingStatusRequests([requests.first], [(TypingOp.stop, narrow)]); @@ -854,7 +856,7 @@ void main() { await enterTopic(tester, narrow: narrow, topic: topicInputText); await tester.enterText(contentInputFinder, 'test content'); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(); } @@ -911,7 +913,7 @@ void main() { group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( - of: find.byIcon(ZulipIcons.send), + of: sendButtonFinder, matching: find.byType(IconButton))); final sendButtonWidget = sendButtonElement.widget as IconButton; final designVariables = DesignVariables.of(sendButtonElement); From c1fbbe5e59f58020d5a295979b2a67570c25f732 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 6 May 2025 12:20:40 -0700 Subject: [PATCH 040/290] dialog: Sweep through error dialogs, giving `message` final punctuation --- assets/l10n/app_en.arb | 6 +++--- lib/generated/l10n/zulip_localizations.dart | 6 +++--- lib/generated/l10n/zulip_localizations_ar.dart | 6 +++--- lib/generated/l10n/zulip_localizations_en.dart | 6 +++--- lib/generated/l10n/zulip_localizations_ja.dart | 6 +++--- lib/generated/l10n/zulip_localizations_nb.dart | 6 +++--- lib/log.dart | 3 +++ lib/widgets/dialog.dart | 3 +++ 8 files changed, 24 insertions(+), 18 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index d2d7b53033..cd5a621230 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -168,7 +168,7 @@ "server": {"type": "String", "example": "https://example.com"} } }, - "errorCouldNotFetchMessageSource": "Could not fetch message source", + "errorCouldNotFetchMessageSource": "Could not fetch message source.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -561,7 +561,7 @@ "url": {"type": "String", "example": "http://chat.example.com/"} } }, - "errorInvalidResponse": "The server sent an invalid response", + "errorInvalidResponse": "The server sent an invalid response.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, @@ -591,7 +591,7 @@ "httpStatus": {"type": "int", "example": "500"} } }, - "errorVideoPlayerFailed": "Unable to play the video", + "errorVideoPlayerFailed": "Unable to play the video.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 6181c7b39b..3cd6c96960 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -359,7 +359,7 @@ abstract class ZulipLocalizations { /// Error message when the source of a message could not be fetched. /// /// In en, this message translates to: - /// **'Could not fetch message source'** + /// **'Could not fetch message source.'** String get errorCouldNotFetchMessageSource; /// Error message when copying the text of a message to the user's system clipboard failed. @@ -863,7 +863,7 @@ abstract class ZulipLocalizations { /// Error message when an API call returned an invalid response. /// /// In en, this message translates to: - /// **'The server sent an invalid response'** + /// **'The server sent an invalid response.'** String get errorInvalidResponse; /// Error message when a network request fails. @@ -893,7 +893,7 @@ abstract class ZulipLocalizations { /// Error message when a video fails to play. /// /// In en, this message translates to: - /// **'Unable to play the video'** + /// **'Unable to play the video.'** String get errorVideoPlayerFailed; /// Error message when URL is empty diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index f26b9d017c..04aa218ce7 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -141,7 +141,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -459,7 +459,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -480,7 +480,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index bfb14645d5..8dc52afb94 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -141,7 +141,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -459,7 +459,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -480,7 +480,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 35088555e2..04cbf3e7bd 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -141,7 +141,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -459,7 +459,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -480,7 +480,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index ae79d99ea9..1c5075cdcc 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -141,7 +141,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -459,7 +459,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -480,7 +480,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; diff --git a/lib/log.dart b/lib/log.dart index c85d228263..64cb409a0e 100644 --- a/lib/log.dart +++ b/lib/log.dart @@ -80,6 +80,7 @@ typedef ReportErrorCallback = void Function( /// /// If `details` is non-null, the [SnackBar] will contain a button that would /// open a dialog containing the error details. +/// Prose in `details` should have final punctuation. // 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. @@ -91,6 +92,8 @@ ReportErrorCancellablyCallback reportErrorToUserBriefly = defaultReportErrorToUs /// 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. +/// +/// Prose in `message` should have final punctuation. // 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. diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 0d2b4c5a7d..08ce8f08c7 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -49,6 +49,9 @@ class DialogStatus { /// /// The [DialogStatus.result] field of the return value can be used /// for waiting for the dialog to be closed. +/// +/// Prose in [message] should have final punctuation: +/// https://github.com/zulip/zulip-flutter/pull/1498#issuecomment-2853578577 // This API is inspired by [ScaffoldManager.showSnackBar]. We wrap // [showDialog]'s return value, a [Future], inside [DialogStatus] // whose documentation can be accessed. This helps avoid confusion when From 13a8b28156426b3fa615d6030605dff02bd48d65 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 9 May 2025 16:17:40 -0700 Subject: [PATCH 041/290] test: Support testing with poll messages in the message list When we set up the message list in tests, we do it by preparing the API response for a message fetch or new-message event, via JSON. But {Stream,Dm}Message.toJson drops poll data on the floor, which defeats setting up a poll-style message in the message list. Until now, anyway, with this workaround. A reference in a dartdoc to something called `prepareMessageWithSubmessages` was dangling; it seemed to be dangling when it first appeared, too, in 5af5c76a8; there's nothing by that name. --- lib/api/model/submessage.dart | 32 +++++++++++++++++++++++++++++--- test/model/message_test.dart | 4 ---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/api/model/submessage.dart b/lib/api/model/submessage.dart index f338265b46..81181f061f 100644 --- a/lib/api/model/submessage.dart +++ b/lib/api/model/submessage.dart @@ -64,6 +64,7 @@ class Submessage { widgetData: widgetData, pollEventSubmessages: submessages.skip(1), messageSenderId: messageSenderId, + debugSubmessages: kDebugMode ? submessages : null, ); case UnsupportedWidgetData(): assert(debugLog('Unsupported widgetData: ${widgetData.json}')); @@ -368,11 +369,13 @@ class Poll extends ChangeNotifier { required PollWidgetData widgetData, required Iterable pollEventSubmessages, required int messageSenderId, + required List? debugSubmessages, }) { final poll = Poll._( messageSenderId: messageSenderId, question: widgetData.extraData.question, options: widgetData.extraData.options, + debugSubmessages: debugSubmessages, ); for (final submessage in pollEventSubmessages) { @@ -386,17 +389,23 @@ class Poll extends ChangeNotifier { required this.messageSenderId, required this.question, required List options, + required List? debugSubmessages, }) { for (int index = 0; index < options.length; index += 1) { // Initial poll options use a placeholder senderId. // See [PollEventSubmessage.optionKey] for details. _addOption(senderId: null, idx: index, option: options[index]); } + if (kDebugMode) { + _debugSubmessages = debugSubmessages; + } } final int messageSenderId; String question; + List? _debugSubmessages; + /// The limit of options any single user can add to a poll. /// /// See https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts#L69-L71 @@ -417,6 +426,14 @@ class Poll extends ChangeNotifier { } _applyEvent(event.senderId, pollEventSubmessage); notifyListeners(); + + if (kDebugMode) { + assert(_debugSubmessages != null); + _debugSubmessages!.add(Submessage( + senderId: event.senderId, + msgType: event.msgType, + content: event.content)); + } } void _applyEvent(int senderId, PollEventSubmessage event) { @@ -472,9 +489,18 @@ class Poll extends ChangeNotifier { } static List toJson(Poll? poll) { - // Rather than maintaining a up-to-date submessages list, return as if it is - // empty, because we are not sending the submessages to the server anyway. - return []; + List? result; + + if (kDebugMode) { + // Useful for setting up a message list with a poll message, which goes + // through this codepath (when preparing a fetch response). + result = poll?._debugSubmessages; + } + + // In prod, rather than maintaining a up-to-date submessages list, + // return as if it is empty, because we are not sending the submessages + // to the server anyway. + return result ?? []; } } diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 2ed4d99490..97c6fbc8b1 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -61,14 +61,10 @@ void main() { /// Perform the initial message fetch for [messageList]. /// /// The test case must have already called [prepare] to initialize the state. - /// - /// This does not support submessages. Use [prepareMessageWithSubmessages] - /// instead if needed. Future prepareMessages( List messages, { bool foundOldest = false, }) async { - assert(messages.every((message) => message.poll == null)); connection.prepare(json: eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); await messageList.fetchInitial(); From 18fee48fb82d34f9b929191c601cb5936bc74ff3 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 9 May 2025 13:11:32 -0700 Subject: [PATCH 042/290] action_sheet test: Silence a noisy warning in an upcoming new test We're about to add a test that renders a poll-style message, and we'd get this warning on that. --- test/widgets/action_sheet_test.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 7d6a5205bb..7ab166bc44 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -79,10 +79,21 @@ Future setupToMessageActionSheet(WidgetTester tester, { // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); - // request the message action sheet - await tester.longPress(find.byType(MessageContent)); + // Request the message action sheet. + // + // We use `warnIfMissed: false` to suppress warnings in cases where + // MessageContent itself didn't hit-test as true but the action sheet still + // opened. The action sheet still opens because the gesture handler is an + // ancestor of MessageContent, but MessageContent might not hit-test as true + // because its render box effectively has HitTestBehavior.deferToChild, and + // the long-press might land where no child hit-tests as true, + // like if it's in padding around a Paragraph. + await tester.longPress(find.byType(MessageContent), warnIfMissed: false); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); + // Check the action sheet did in fact open, so we don't defeat any tests that + // use simple `find.byIcon`-style checks to test presence/absence of a button. + check(find.byType(BottomSheet)).findsOne(); } void main() { From c7863c02c9fb2bcca3502fbe9c94e43bc5622f69 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 12 May 2025 18:30:31 -0700 Subject: [PATCH 043/290] compose [nfc]: Give Compose{Content,Topic}Controller a starting-text param --- lib/widgets/compose_box.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index bfd91d6afd..acdfa170b5 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -82,6 +82,8 @@ const double _composeButtonSize = 44; /// /// Subclasses must ensure that [_update] is called in all exposed constructors. abstract class ComposeController extends TextEditingController { + ComposeController({super.text}); + int get maxLengthUnicodeCodePoints; String get textNormalized => _textNormalized; @@ -143,7 +145,7 @@ enum TopicValidationError { } class ComposeTopicController extends ComposeController { - ComposeTopicController({required this.store}) { + ComposeTopicController({super.text, required this.store}) { _update(); } @@ -226,7 +228,7 @@ enum ContentValidationError { } class ComposeContentController extends ComposeController { - ComposeContentController() { + ComposeContentController({super.text}) { _update(); } From ba65dc58b08dc5754172259ab74c944574a5577a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 15 May 2025 13:34:33 -0700 Subject: [PATCH 044/290] test: eg.initialSnapshot.realmMessageContentEditLimitSeconds default null --- test/example_data.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/example_data.dart b/test/example_data.dart index 9577ef0e73..e0f44f9ddc 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -971,7 +971,7 @@ InitialSnapshot initialSnapshot({ realmMandatoryTopics: realmMandatoryTopics ?? true, realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, realmAllowMessageEditing: realmAllowMessageEditing ?? true, - realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds ?? 600, + realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, serverEmojiDataUrl: serverEmojiDataUrl From 99c530acc08dbc10d800728d3760dc88a3591ecc Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 15 May 2025 13:36:41 -0700 Subject: [PATCH 045/290] test: Add FakeApiConnection.clearPreparedResponses --- test/api/fake_api.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index 2b230376ac..2382ab859b 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -81,6 +81,10 @@ class FakeHttpClient extends http.BaseClient { } } + void clearPreparedResponses() { + _preparedResponses.clear(); + } + @override Future send(http.BaseRequest request) { _requestHistory.add(request); @@ -278,6 +282,10 @@ class FakeApiConnection extends ApiConnection { delay: delay, ); } + + void clearPreparedResponses() { + client.clearPreparedResponses(); + } } extension FakeApiConnectionChecks on Subject { From ec811eec734823c6c6e8bc79950a581932cc5beb Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 15 May 2025 13:39:02 -0700 Subject: [PATCH 046/290] message: Include both {new,originalRaw}Content in takeFailedMessageEdit Greg points out that we'll need both values when restoring a failed message edit: originalRawContent for the eventual edit-message request (for prevContentSha256), and newContent to fill the edit-message compose box, so the user can restore the edit session to what it was before it failed. --- lib/model/message.dart | 18 +++++++++++++----- lib/model/store.dart | 2 +- test/model/message_test.dart | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 1da91e9b0d..2573cfadc6 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -63,13 +63,18 @@ mixin MessageStore { /// /// Should only be called when there is a failed request, /// per [getEditMessageErrorStatus]. - String takeFailedMessageEdit(int messageId); + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId); } class _EditMessageRequestStatus { - _EditMessageRequestStatus({required this.hasError, required this.newContent}); + _EditMessageRequestStatus({ + required this.hasError, + required this.originalRawContent, + required this.newContent, + }); bool hasError; + final String originalRawContent; final String newContent; } @@ -185,7 +190,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } _editMessageRequests[messageId] = _EditMessageRequestStatus( - hasError: false, newContent: newContent); + hasError: false, originalRawContent: originalRawContent, newContent: newContent); _notifyMessageListViewsForOneMessage(messageId); try { await updateMessage(connection, @@ -210,7 +215,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } @override - String takeFailedMessageEdit(int messageId) { + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { final status = _editMessageRequests.remove(messageId); _notifyMessageListViewsForOneMessage(messageId); if (status == null) { @@ -219,7 +224,10 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { if (!status.hasError) { throw StateError("called takeFailedMessageEdit, but edit hasn't failed"); } - return status.newContent; + return ( + originalRawContent: status.originalRawContent, + newContent: status.newContent + ); } void handleUserTopicEvent(UserTopicEvent event) { diff --git a/lib/model/store.dart b/lib/model/store.dart index f7a4e0ca4e..5f5b19128b 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -765,7 +765,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor originalRawContent: originalRawContent, newContent: newContent); } @override - String takeFailedMessageEdit(int messageId) { + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { assert(!_disposed); return _messages.takeFailedMessageEdit(messageId); } diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 97c6fbc8b1..1809f0888b 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -252,7 +252,7 @@ void main() { check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); checkNotifiedOnce(); - check(store.takeFailedMessageEdit(message.id)).equals('new content'); + check(store.takeFailedMessageEdit(message.id).newContent).equals('new content'); check(store.getEditMessageErrorStatus(message.id)).isNull(); checkNotifiedOnce(); })); @@ -338,7 +338,7 @@ void main() { async.elapse(Duration(seconds: 1)); check(store.getEditMessageErrorStatus(message.id)).isNotNull().isTrue(); checkNotifiedOnce(); - check(store.takeFailedMessageEdit(message.id)).equals('new content'); + check(store.takeFailedMessageEdit(message.id).newContent).equals('new content'); checkNotifiedOnce(); await store.handleEvent(eg.updateMessageEditEvent(message)); // no error From af36660e4081c600dffa5591c76680c55904e796 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 15 May 2025 13:53:17 -0700 Subject: [PATCH 047/290] compose: Prepare some leafward widgets to support a disabled state In the upcoming edit-message feature, the edit-message compose box will have a "Preparing..." state after you tap "Edit message" in the action sheet, while we're fetching the raw message content. The edit-message compose box shouldn't be interactable in this state. --- lib/widgets/compose_box.dart | 64 ++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index acdfa170b5..dfc55bf193 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -493,11 +493,13 @@ class _ContentInput extends StatelessWidget { required this.narrow, required this.controller, required this.hintText, + this.enabled = true, // ignore: unused_element_parameter }); final Narrow narrow; final ComposeBoxController controller; final String hintText; + final bool enabled; static double maxHeight(BuildContext context) { final clampingTextScaler = MediaQuery.textScalerOf(context) @@ -540,6 +542,7 @@ class _ContentInput extends StatelessWidget { top: _verticalPadding, bottom: _verticalPadding, color: designVariables.composeBoxBg, child: TextField( + enabled: enabled, controller: controller.content, focusNode: controller.contentFocusNode, // Let the content show through the `contentPadding` so that @@ -957,9 +960,10 @@ Future _uploadFiles({ } abstract class _AttachUploadsButton extends StatelessWidget { - const _AttachUploadsButton({required this.controller}); + const _AttachUploadsButton({required this.controller, required this.enabled}); final ComposeBoxController controller; + final bool enabled; IconData get icon; String tooltip(ZulipLocalizations zulipLocalizations); @@ -1001,7 +1005,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { child: IconButton( icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), tooltip: tooltip(zulipLocalizations), - onPressed: () => _handlePress(context))); + onPressed: enabled ? () => _handlePress(context) : null)); } } @@ -1060,7 +1064,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) } class _AttachFileButton extends _AttachUploadsButton { - const _AttachFileButton({required super.controller}); + const _AttachFileButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.attach_file; @@ -1076,7 +1080,7 @@ class _AttachFileButton extends _AttachUploadsButton { } class _AttachMediaButton extends _AttachUploadsButton { - const _AttachMediaButton({required super.controller}); + const _AttachMediaButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.image; @@ -1093,7 +1097,7 @@ class _AttachMediaButton extends _AttachUploadsButton { } class _AttachFromCameraButton extends _AttachUploadsButton { - const _AttachFromCameraButton({required super.controller}); + const _AttachFromCameraButton({required super.controller, required super.enabled}); @override IconData get icon => ZulipIcons.camera; @@ -1370,6 +1374,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { Widget? buildTopicInput(); Widget buildContentInput(); + bool getComposeButtonsEnabled(BuildContext context); Widget buildSendButton(); @override @@ -1396,10 +1401,11 @@ abstract class _ComposeBoxBody extends StatelessWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4))))); + final composeButtonsEnabled = getComposeButtonsEnabled(context); final composeButtons = [ - _AttachFileButton(controller: controller), - _AttachMediaButton(controller: controller), - _AttachFromCameraButton(controller: controller), + _AttachFileButton(controller: controller, enabled: composeButtonsEnabled), + _AttachMediaButton(controller: controller, enabled: composeButtonsEnabled), + _AttachFromCameraButton(controller: controller, enabled: composeButtonsEnabled), ]; final topicInput = buildTopicInput(); @@ -1449,6 +1455,8 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { controller: controller, ); + @override bool getComposeButtonsEnabled(BuildContext context) => true; + @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => StreamDestination( @@ -1472,6 +1480,8 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { controller: controller, ); + @override bool getComposeButtonsEnabled(BuildContext context) => true; + @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => narrow.destination, @@ -1756,7 +1766,8 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM final errorBanner = _errorBannerComposingNotAllowed(context); if (errorBanner != null) { - return _ComposeBoxContainer(body: null, banner: errorBanner); + return ComposeBoxInheritedWidget.fromComposeBoxState(this, + child: _ComposeBoxContainer(body: null, banner: errorBanner)); } final controller = this.controller; @@ -1777,6 +1788,39 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM // errorBanner = _ErrorBanner(label: // ZulipLocalizations.of(context).errorSendMessageTimeout); // } - return _ComposeBoxContainer(body: body, banner: null); + return ComposeBoxInheritedWidget.fromComposeBoxState(this, + child: _ComposeBoxContainer(body: body, banner: null)); + } +} + +/// An [InheritedWidget] to provide data to leafward [StatelessWidget]s, +/// such as flags that should cause the upload buttons to be disabled. +class ComposeBoxInheritedWidget extends InheritedWidget { + factory ComposeBoxInheritedWidget.fromComposeBoxState( + ComposeBoxState state, { + required Widget child, + }) { + return ComposeBoxInheritedWidget._( + // TODO add fields + child: child, + ); + } + + const ComposeBoxInheritedWidget._({ + // TODO add fields + required super.child, + }); + + // TODO add fields + + @override + bool updateShouldNotify(covariant ComposeBoxInheritedWidget oldWidget) => + // TODO compare fields + false; + + static ComposeBoxInheritedWidget of(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType(); + assert(widget != null, 'No ComposeBoxInheritedWidget ancestor'); + return widget!; } } From 77ab9300a83045d6336946d73d55b3792c046413 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 2 May 2025 11:56:40 -0700 Subject: [PATCH 048/290] compose: Support editing an already-sent message Fixes: #126 --- assets/l10n/app_en.arb | 56 ++ lib/generated/l10n/zulip_localizations.dart | 84 +++ .../l10n/zulip_localizations_ar.dart | 45 ++ .../l10n/zulip_localizations_en.dart | 45 ++ .../l10n/zulip_localizations_ja.dart | 45 ++ .../l10n/zulip_localizations_nb.dart | 45 ++ .../l10n/zulip_localizations_pl.dart | 45 ++ .../l10n/zulip_localizations_ru.dart | 45 ++ .../l10n/zulip_localizations_sk.dart | 45 ++ .../l10n/zulip_localizations_uk.dart | 45 ++ lib/widgets/action_sheet.dart | 52 ++ lib/widgets/compose_box.dart | 310 ++++++++++- lib/widgets/message_list.dart | 98 +++- lib/widgets/theme.dart | 21 + test/flutter_checks.dart | 4 + test/widgets/action_sheet_test.dart | 173 +++++- test/widgets/compose_box_checks.dart | 15 + test/widgets/compose_box_test.dart | 505 +++++++++++++++++- test/widgets/message_list_test.dart | 79 ++- 19 files changed, 1736 insertions(+), 21 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index cd5a621230..d11bf43eda 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -144,6 +144,10 @@ "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, + "actionSheetOptionEditMessage": "Edit message", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, "actionSheetOptionMarkTopicAsRead": "Mark topic as read", "@actionSheetOptionMarkTopicAsRead": { "description": "Option to mark a specific topic as read in the action sheet." @@ -219,6 +223,10 @@ "@errorMessageNotSent": { "description": "Error message for compose box when a message could not be sent." }, + "errorMessageEditNotSaved": "Message not saved", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, "errorLoginCouldNotConnect": "Failed to connect to server:\n{url}", "@errorLoginCouldNotConnect": { "description": "Error message when the app could not connect to the server.", @@ -309,6 +317,10 @@ "@errorUnstarMessageFailedTitle": { "description": "Error title when unstarring a message failed." }, + "errorCouldNotEditMessageTitle": "Could not edit message", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, "successLinkCopied": "Link copied", "@successLinkCopied": { "description": "Success message after copy link action completed." @@ -329,6 +341,46 @@ "@errorBannerCannotPostInChannelLabel": { "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." }, + "composeBoxBannerLabelEditMessage": "Edit message", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonCancel": "Cancel", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonSave": "Save", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Cannot edit message", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "An edit is already in progress. Please wait for it to complete.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "SAVING EDIT…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "EDIT NOT SAVED", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Discard the message you’re writing?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", + "@discardDraftConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Discard", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, "composeBoxAttachFilesTooltip": "Attach files", "@composeBoxAttachFilesTooltip": { "description": "Tooltip for compose box icon to attach a file to the message." @@ -367,6 +419,10 @@ "destination": {"type": "String", "example": "#channel name > topic name"} } }, + "preparingEditMessageContentInput": "Preparing…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, "composeBoxSendTooltip": "Send", "@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 3cd6c96960..75b70a0377 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -326,6 +326,12 @@ abstract class ZulipLocalizations { /// **'Unstar message'** String get actionSheetOptionUnstarMessage; + /// Label for the 'Edit message' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'Edit message'** + String get actionSheetOptionEditMessage; + /// Option to mark a specific topic as read in the action sheet. /// /// In en, this message translates to: @@ -414,6 +420,12 @@ abstract class ZulipLocalizations { /// **'Message not sent'** String get errorMessageNotSent; + /// Error message for compose box when a message edit could not be saved. + /// + /// In en, this message translates to: + /// **'Message not saved'** + String get errorMessageEditNotSaved; + /// Error message when the app could not connect to the server. /// /// In en, this message translates to: @@ -526,6 +538,12 @@ abstract class ZulipLocalizations { /// **'Failed to unstar message'** String get errorUnstarMessageFailedTitle; + /// Error title when an exception prevented us from opening the compose box for editing a message. + /// + /// In en, this message translates to: + /// **'Could not edit message'** + String get errorCouldNotEditMessageTitle; + /// Success message after copy link action completed. /// /// In en, this message translates to: @@ -556,6 +574,66 @@ abstract class ZulipLocalizations { /// **'You do not have permission to post in this channel.'** String get errorBannerCannotPostInChannelLabel; + /// Label text for the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Edit message'** + String get composeBoxBannerLabelEditMessage; + + /// Label text for the 'Cancel' button in the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get composeBoxBannerButtonCancel; + + /// Label text for the 'Save' button in the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Save'** + String get composeBoxBannerButtonSave; + + /// Error title when a message edit cannot be saved because there is another edit already in progress. + /// + /// In en, this message translates to: + /// **'Cannot edit message'** + String get editAlreadyInProgressTitle; + + /// Error message when a message edit cannot be saved because there is another edit already in progress. + /// + /// In en, this message translates to: + /// **'An edit is already in progress. Please wait for it to complete.'** + String get editAlreadyInProgressMessage; + + /// Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'SAVING EDIT…'** + String get savingMessageEditLabel; + + /// Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'EDIT NOT SAVED'** + String get savingMessageEditFailedLabel; + + /// Title for a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'Discard the message you’re writing?'** + String get discardDraftConfirmationDialogTitle; + + /// Message for a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'When you edit a message, the content that was previously in the compose box is discarded.'** + String get discardDraftConfirmationDialogMessage; + + /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'Discard'** + String get discardDraftConfirmationDialogConfirmButton; + /// Tooltip for compose box icon to attach a file to the message. /// /// In en, this message translates to: @@ -604,6 +682,12 @@ abstract class ZulipLocalizations { /// **'Message {destination}'** String composeBoxChannelContentHint(String destination); + /// Hint text for content input when the compose box is preparing to edit a message. + /// + /// In en, this message translates to: + /// **'Preparing…'** + String get preparingEditMessageContentInput; + /// Tooltip for send button in compose box. /// /// 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 04aa218ce7..98dd9a7af6 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -122,6 +122,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -191,6 +194,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,39 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -307,6 +349,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 8dc52afb94..0a746ff634 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -122,6 +122,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -191,6 +194,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,39 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -307,6 +349,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 04cbf3e7bd..74a2d4bedb 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -122,6 +122,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -191,6 +194,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,39 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -307,6 +349,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 1c5075cdcc..02913278b8 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -122,6 +122,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -191,6 +194,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,39 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -307,6 +349,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 15f61bd882..657e1757d1 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -127,6 +127,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odbierz gwiazdkę'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Oznacz wątek jako przeczytany'; @@ -197,6 +200,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorMessageNotSent => 'Nie wysłano wiadomości'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Nie udało się połączyć z serwerem:\n$url'; @@ -269,6 +275,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorUnstarMessageFailedTitle => 'Odebranie gwiazdki bez powodzenia'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Skopiowano odnośnik'; @@ -286,6 +295,39 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'Nie masz uprawnień do dodawania wpisów w tym kanale.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Dołącz pliki'; @@ -314,6 +356,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Wiadomość do $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Wyślij'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index e4248179e2..5d8899290d 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -127,6 +127,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Отметить тему как прочитанную'; @@ -197,6 +200,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorMessageNotSent => 'Сообщение не отправлено'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Не удалось подключиться к серверу:\n$url'; @@ -270,6 +276,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorUnstarMessageFailedTitle => 'Не удалось снять отметку с сообщения'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Ссылка скопирована'; @@ -287,6 +296,39 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'У вас нет права писать в этом канале.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Прикрепить файлы'; @@ -315,6 +357,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Сообщение для $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Отправить'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index baded578ba..3ff534eca5 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -123,6 +123,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odhviezdičkovať správu'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -192,6 +195,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorMessageNotSent => 'Správa nebola odoslaná'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Nepodarilo sa pripojiť na server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,39 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -307,6 +349,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Message $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Send'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 50b1029dd7..a756bdba6a 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -128,6 +128,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionUnstarMessage => 'Зняти позначку зірочки з повідомлення'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану'; @@ -197,6 +200,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorMessageNotSent => 'Повідомлення не надіслано'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Не вдалося підключитися до сервера:\n$url'; @@ -270,6 +276,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorUnstarMessageFailedTitle => 'Не вдалося зняти позначку зірочки з повідомлення'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Посилання скопійовано'; @@ -288,6 +297,39 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'Ви не маєте дозволу на публікацію в цьому каналі.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Прикріпити файли'; @@ -316,6 +358,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Надіслати повідомлення $destination'; } + @override + String get preparingEditMessageContentInput => 'Preparing…'; + @override String get composeBoxSendTooltip => 'Надіслати'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 4beea3db66..43db39dc18 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -12,6 +12,7 @@ import '../api/model/model.dart'; import '../api/route/channels.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; @@ -576,6 +577,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes CopyMessageTextButton(message: message, pageContext: pageContext), CopyMessageLinkButton(message: message, pageContext: pageContext), ShareButton(message: message, pageContext: pageContext), + if (_getShouldShowEditButton(pageContext, message)) + EditButton(message: message, pageContext: pageContext), ]; _showActionSheet(pageContext, optionButtons: optionButtons); @@ -591,6 +594,36 @@ abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButto final Message message; } +bool _getShouldShowEditButton(BuildContext pageContext, Message message) { + final store = PerAccountStoreWidget.of(pageContext); + + final messageListPage = MessageListPage.ancestorOf(pageContext); + final composeBoxState = messageListPage.composeBoxState; + final isComposeBoxOffered = composeBoxState != null; + final composeBoxController = composeBoxState?.controller; + + final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); + final editMessageInProgress = + // The compose box is in edit-message mode, with Cancel/Save instead of Send. + composeBoxController is EditMessageComposeBoxController + // An edit request is in progress or the error state. + || editMessageErrorStatus != null; + + final now = ZulipBinding.instance.utcNow().millisecondsSinceEpoch ~/ 1000; + final editLimit = store.realmMessageContentEditLimitSeconds; + final outsideEditLimit = + editLimit != null + && editLimit != 0 // TODO(server-6) remove (pre-FL 138, 0 represents no limit) + && now - message.timestamp > editLimit; + + return message.senderId == store.selfUserId + && isComposeBoxOffered + && store.realmAllowMessageEditing + && !outsideEditLimit + && !editMessageInProgress + && message.poll == null; // messages with polls cannot be edited +} + class ReactionButtons extends StatelessWidget { const ReactionButtons({ super.key, @@ -955,3 +988,22 @@ class ShareButton extends MessageActionSheetMenuItemButton { } } } + +class EditButton extends MessageActionSheetMenuItemButton { + EditButton({super.key, required super.message, required super.pageContext}); + + @override + IconData get icon => ZulipIcons.edit; + + @override + String label(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.actionSheetOptionEditMessage; + + @override void onPressed() async { + final composeBoxState = findMessageListPage().composeBoxState; + if (composeBoxState == null) { + throw StateError('Compose box unexpectedly absent when edit-message button pressed'); + } + composeBoxState.startEditInteraction(message.id); + } +} diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index dfc55bf193..d629e69029 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:app_settings/app_settings.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:mime/mime.dart'; @@ -14,7 +15,9 @@ import '../model/binding.dart'; import '../model/compose.dart'; import '../model/narrow.dart'; import '../model/store.dart'; +import 'actions.dart'; import 'autocomplete.dart'; +import 'button.dart'; import 'color.dart'; import 'dialog.dart'; import 'icons.dart'; @@ -228,10 +231,13 @@ enum ContentValidationError { } class ComposeContentController extends ComposeController { - ComposeContentController({super.text}) { + ComposeContentController({super.text, this.requireNotEmpty = true}) { _update(); } + /// Whether to produce [ContentValidationError.empty]. + final bool requireNotEmpty; + // TODO(#1237) use `max_message_length` instead of hardcoded limit @override final maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; @@ -378,7 +384,7 @@ class ComposeContentController extends ComposeController @override List _computeValidationErrors() { return [ - if (textNormalized.isEmpty) + if (requireNotEmpty && textNormalized.isEmpty) ContentValidationError.empty, if ( @@ -492,13 +498,13 @@ class _ContentInput extends StatelessWidget { const _ContentInput({ required this.narrow, required this.controller, - required this.hintText, - this.enabled = true, // ignore: unused_element_parameter + this.hintText, + this.enabled = true, }); final Narrow narrow; final ComposeBoxController controller; - final String hintText; + final String? hintText; final bool enabled; static double maxHeight(BuildContext context) { @@ -873,6 +879,31 @@ class _FixedDestinationContentInput extends StatelessWidget { } } +class _EditMessageContentInput extends StatelessWidget { + const _EditMessageContentInput({ + required this.narrow, + required this.controller, + }); + + final Narrow narrow; + final EditMessageComposeBoxController controller; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final awaitingRawContent = ComposeBoxInheritedWidget.of(context) + .awaitingRawMessageContentForEdit; + return _ContentInput( + narrow: narrow, + controller: controller, + enabled: !awaitingRawContent, + hintText: awaitingRawContent + ? zulipLocalizations.preparingEditMessageContentInput + : null, + ); + } +} + /// Data on a file to be uploaded, from any source. /// /// A convenience class to represent data from the generic file picker, @@ -1375,7 +1406,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { Widget? buildTopicInput(); Widget buildContentInput(); bool getComposeButtonsEnabled(BuildContext context); - Widget buildSendButton(); + Widget? buildSendButton(); @override Widget build(BuildContext context) { @@ -1409,6 +1440,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { ]; final topicInput = buildTopicInput(); + final sendButton = buildSendButton(); return Column(children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1426,7 +1458,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row(children: composeButtons), - buildSendButton(), + if (sendButton != null) sendButton, ]))), ]); } @@ -1488,6 +1520,28 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { ); } +/// A compose box for editing an already-sent message. +class _EditMessageComposeBoxBody extends _ComposeBoxBody { + _EditMessageComposeBoxBody({required this.narrow, required this.controller}); + + @override + final Narrow narrow; + + @override + final EditMessageComposeBoxController controller; + + @override Widget? buildTopicInput() => null; + + @override Widget buildContentInput() => _EditMessageContentInput( + narrow: narrow, + controller: controller); + + @override bool getComposeButtonsEnabled(BuildContext context) => + !ComposeBoxInheritedWidget.of(context).awaitingRawMessageContentForEdit; + + @override Widget? buildSendButton() => null; +} + sealed class ComposeBoxController { final content = ComposeContentController(); final contentFocusNode = FocusNode(); @@ -1566,6 +1620,28 @@ class StreamComposeBoxController extends ComposeBoxController { class FixedDestinationComposeBoxController extends ComposeBoxController {} +class EditMessageComposeBoxController extends ComposeBoxController { + EditMessageComposeBoxController({ + required this.messageId, + required this.originalRawContent, + required String? initialText, + }) : _content = ComposeContentController( + text: initialText, + // Editing to delete the content is a supported form of + // deletion: https://zulip.com/help/delete-a-message#delete-message-content + requireNotEmpty: false); + + factory EditMessageComposeBoxController.empty(int messageId) => + EditMessageComposeBoxController(messageId: messageId, + originalRawContent: null, initialText: null); + + @override ComposeContentController get content => _content; + final ComposeContentController _content; + + final int messageId; + String? originalRawContent; +} + abstract class _Banner extends StatelessWidget { const _Banner(); @@ -1656,6 +1732,68 @@ class _ErrorBanner extends _Banner { } } +class _EditMessageBanner extends _Banner { + const _EditMessageBanner({required this.composeBoxState}); + + final ComposeBoxState composeBoxState; + + @override + String getLabel(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxBannerLabelEditMessage; + + @override + Color getLabelColor(DesignVariables designVariables) => + designVariables.bannerTextIntInfo; + + @override + Color getBackgroundColor(DesignVariables designVariables) => + designVariables.bannerBgIntInfo; + + void _handleTapSave (BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final controller = composeBoxState.controller; + if (controller is! EditMessageComposeBoxController) return; // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + + if (controller.content.hasValidationErrors.value) { + final validationErrorMessages = + controller.content.validationErrors.map((error) => + error.message(zulipLocalizations)); + showErrorDialog(context: context, + title: zulipLocalizations.errorMessageEditNotSaved, + message: validationErrorMessages.join('\n\n')); + return; + } + + final originalRawContent = controller.originalRawContent; + if (originalRawContent == null) { + // Fetch-raw-content request hasn't finished; try again later. + // TODO show error dialog? + return; + } + + store.editMessage( + messageId: controller.messageId, + originalRawContent: originalRawContent, + newContent: controller.content.textNormalized); + composeBoxState.endEditInteraction(); + } + + @override + Widget buildTrailing(context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: [ + ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonCancel, + onPressed: composeBoxState.endEditInteraction), + // TODO(#1481) disabled appearance when there are validation errors + // or the original raw content hasn't loaded yet + ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonSave, + attention: ZulipWebUiKitButtonAttention.high, + onPressed: () => _handleTapSave(context)), + ]); + } +} + /// The compose box. /// /// Takes the full screen width, covering the horizontal insets with its surface. @@ -1687,12 +1825,146 @@ class ComposeBox extends StatefulWidget { /// The interface for the state of a [ComposeBox]. abstract class ComposeBoxState extends State { ComposeBoxController get controller; + + /// Switch the compose box to editing mode. + /// + /// If there is already text in the compose box, gives a confirmation dialog + /// to confirm that it is OK to discard that text. + /// + /// If called from the message action sheet, fetches the raw message content + /// to fill in the edit-message compose box. + /// + /// If called by tapping a message in the message list with 'EDIT NOT SAVED', + /// fills the edit-message compose box with the content the user wanted + /// in the edit request that failed. + void startEditInteraction(int messageId); + + /// Switch the compose box back to regular non-edit mode, with no content. + void endEditInteraction(); } class _ComposeBoxState extends State with PerAccountStoreAwareStateMixin implements ComposeBoxState { @override ComposeBoxController get controller => _controller!; ComposeBoxController? _controller; + @override + void startEditInteraction(int messageId) async { + if (await _abortBecauseContentInputNotEmpty()) return; + if (!mounted) return; + + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + switch (store.getEditMessageErrorStatus(messageId)) { + case null: + _editFromRawContentFetch(messageId); + case true: + _editByRestoringFailedEdit(messageId); + case false: + // This can happen if you start an edit interaction on one + // MessageListPage and then do an edit on a different MessageListPage, + // and the second edit is still saving when you return to the first. + // + // Abort rather than sending a request with a prevContentSha256 + // that the server might not accept, and don't clear the compose + // box, so the user can try again after the request settles. + // TODO could write a test for this + showErrorDialog(context: context, + title: zulipLocalizations.editAlreadyInProgressTitle, + message: zulipLocalizations.editAlreadyInProgressMessage); + return; + } + } + + /// If there's text in the compose box, give a confirmation dialog + /// asking if it can be discarded and await the result. + Future _abortBecauseContentInputNotEmpty() async { + final zulipLocalizations = ZulipLocalizations.of(context); + if (controller.content.textNormalized.isNotEmpty) { + final dialog = showSuggestedActionDialog(context: context, + title: zulipLocalizations.discardDraftConfirmationDialogTitle, + message: zulipLocalizations.discardDraftConfirmationDialogMessage, + // TODO(#1032) "destructive" style for action button + actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton); + if (await dialog.result != true) return true; + } + return false; + } + + void _editByRestoringFailedEdit(int messageId) { + final store = PerAccountStoreWidget.of(context); + // Fill the content input with the content the user wanted in the failed + // edit attempt, not the original content. + // Side effect: Clears the "EDIT NOT SAVED" text in the message list. + final failedEdit = store.takeFailedMessageEdit(messageId); + setState(() { + controller.dispose(); + _controller = EditMessageComposeBoxController( + messageId: messageId, + originalRawContent: failedEdit.originalRawContent, + initialText: failedEdit.newContent, + ) + ..contentFocusNode.requestFocus(); + }); + } + + void _editFromRawContentFetch(int messageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + final emptyEditController = EditMessageComposeBoxController.empty(messageId); + setState(() { + controller.dispose(); + _controller = emptyEditController; + }); + final fetchedRawContent = await ZulipAction.fetchRawContentWithFeedback( + context: context, + messageId: messageId, + errorDialogTitle: zulipLocalizations.errorCouldNotEditMessageTitle, + ); + // TODO timeout this request? + if (!mounted) return; + if (!identical(controller, emptyEditController)) { + // user tapped Cancel during the fetch-raw-content request + // TODO in this case we don't want the error dialog caused by + // ZulipAction.fetchRawContentWithFeedback; suppress that + return; + } + if (fetchedRawContent == null) { + // Fetch-raw-content failed; abort the edit session. + // An error dialog was already shown, by fetchRawContentWithFeedback. + setState(() { + controller.dispose(); + _setNewController(PerAccountStoreWidget.of(context)); + }); + return; + } + // TODO scroll message list to ensure the message is still in view; + // highlight it? + assert(controller is EditMessageComposeBoxController); + final editMessageController = controller as EditMessageComposeBoxController; + setState(() { + // setState to refresh the input, upload buttons, etc. + // out of the disabled "Preparing…" state. + editMessageController.originalRawContent = fetchedRawContent; + }); + editMessageController.content.value = TextEditingValue(text: fetchedRawContent); + SchedulerBinding.instance.addPostFrameCallback((_) { + // post-frame callback so this happens after the input is enabled + editMessageController.contentFocusNode.requestFocus(); + }); + } + + @override + void endEditInteraction() { + assert(controller is EditMessageComposeBoxController); + if (controller is! EditMessageComposeBoxController) return; // TODO(log) + + final store = PerAccountStoreWidget.of(context); + setState(() { + controller.dispose(); + _setNewController(store); + }); + } + @override void onNewStore() { final newStore = PerAccountStoreWidget.of(context); @@ -1707,6 +1979,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case StreamComposeBoxController(): controller.topic.store = newStore; case FixedDestinationComposeBoxController(): + case EditMessageComposeBoxController(): // no reference to the store that needs updating } } @@ -1762,14 +2035,15 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM @override Widget build(BuildContext context) { - final Widget? body; - final errorBanner = _errorBannerComposingNotAllowed(context); if (errorBanner != null) { return ComposeBoxInheritedWidget.fromComposeBoxState(this, child: _ComposeBoxContainer(body: null, banner: errorBanner)); } + final Widget? body; + Widget? banner; + final controller = this.controller; final narrow = widget.narrow; switch (controller) { @@ -1781,6 +2055,10 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM narrow as SendableNarrow; body = _FixedDestinationComposeBoxBody(controller: controller, narrow: narrow); } + case EditMessageComposeBoxController(): { + body = _EditMessageComposeBoxBody(controller: controller, narrow: narrow); + banner = _EditMessageBanner(composeBoxState: this); + } } // TODO(#720) dismissable message-send error, maybe something like: @@ -1789,7 +2067,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM // ZulipLocalizations.of(context).errorSendMessageTimeout); // } return ComposeBoxInheritedWidget.fromComposeBoxState(this, - child: _ComposeBoxContainer(body: body, banner: null)); + child: _ComposeBoxContainer(body: body, banner: banner)); } } @@ -1800,23 +2078,25 @@ class ComposeBoxInheritedWidget extends InheritedWidget { ComposeBoxState state, { required Widget child, }) { + final controller = state.controller; return ComposeBoxInheritedWidget._( - // TODO add fields + awaitingRawMessageContentForEdit: + controller is EditMessageComposeBoxController + && controller.originalRawContent == null, child: child, ); } const ComposeBoxInheritedWidget._({ - // TODO add fields + required this.awaitingRawMessageContentForEdit, required super.child, }); - // TODO add fields + final bool awaitingRawMessageContentForEdit; @override bool updateShouldNotify(covariant ComposeBoxInheritedWidget oldWidget) => - // TODO compare fields - false; + awaitingRawMessageContentForEdit != oldWidget.awaitingRawMessageContentForEdit; static ComposeBoxInheritedWidget of(BuildContext context) { final widget = context.dependOnInheritedWidgetOfExactType(); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 8c280eac1c..72f646cc65 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1449,6 +1449,7 @@ class MessageWithPossibleSender extends StatelessWidget { @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); final message = item.message; @@ -1473,6 +1474,25 @@ class MessageWithPossibleSender extends StatelessWidget { child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); } + Widget content = MessageContent(message: message, content: item.content); + + final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); + if (editMessageErrorStatus != null) { + // The Figma also fades the sender row: + // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2076574000 + // We've decided to just fade the message content because that's the only + // thing that's changing. + content = Opacity(opacity: 0.6, child: content); + if (!editMessageErrorStatus) { + // IgnorePointer neutralizes interactable message content like links; + // this seemed appropriate along with the faded appearance. + content = IgnorePointer(child: content); + } else { + content = _RestoreEditMessageGestureDetector(messageId: message.id, + child: content); + } + } + return GestureDetector( behavior: HitTestBehavior.translucent, onLongPress: () => showMessageActionSheet(context: context, message: message), @@ -1489,10 +1509,12 @@ class MessageWithPossibleSender extends StatelessWidget { Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - MessageContent(message: message, content: item.content), + content, if ((message.reactions?.total ?? 0) > 0) ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editStateText != null) + if (editMessageErrorStatus != null) + _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) + else if (editStateText != null) Text(editStateText, textAlign: TextAlign.end, style: TextStyle( @@ -1508,3 +1530,75 @@ class MessageWithPossibleSender extends StatelessWidget { ]))); } } + +class _EditMessageStatusRow extends StatelessWidget { + const _EditMessageStatusRow({ + required this.messageId, + required this.status, + }); + + final int messageId; + final bool status; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final baseTextStyle = TextStyle( + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12)); + + return switch (status) { + // TODO parse markdown and show new content as local echo? + false => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 1.5, + children: [ + Text( + style: baseTextStyle + .copyWith(color: designVariables.btnLabelAttLowIntInfo), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditLabel), + // TODO instead place within bottom outer padding: + // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2087576108 + LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withValues(alpha: 0.5), + backgroundColor: designVariables.foreground.withValues(alpha: 0.2), + ), + ]), + true => _RestoreEditMessageGestureDetector( + messageId: messageId, + child: Text( + style: baseTextStyle + .copyWith(color: designVariables.btnLabelAttLowIntDanger), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditFailedLabel)), + }; + } +} + +class _RestoreEditMessageGestureDetector extends StatelessWidget { + const _RestoreEditMessageGestureDetector({ + required this.messageId, + required this.child, + }); + + final int messageId; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + if (composeBoxState == null) return; + composeBoxState.startEditInteraction(messageId); + }, + child: child); + } +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index a533311869..276e308b2b 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -130,6 +130,8 @@ class DesignVariables extends ThemeExtension { static final light = DesignVariables._( background: const Color(0xffffffff), bannerBgIntDanger: const Color(0xfff2e4e4), + bannerBgIntInfo: const Color(0xffddecf6), + bannerTextIntInfo: const Color(0xff06037c), bgBotBar: const Color(0xfff6f6f6), bgContextMenu: const Color(0xfff2f2f2), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), @@ -144,6 +146,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: const Color(0xff3c6bff).withValues(alpha: 0.12), btnLabelAttHigh: const Color(0xffffffff), btnLabelAttLowIntDanger: const Color(0xffc0070a), + btnLabelAttLowIntInfo: const Color(0xff2347c6), btnLabelAttMediumIntDanger: const Color(0xffac0508), btnLabelAttMediumIntInfo: const Color(0xff1027a6), btnShadowAttMed: const Color(0xff000000).withValues(alpha: 0.20), @@ -187,6 +190,8 @@ class DesignVariables extends ThemeExtension { static final dark = DesignVariables._( background: const Color(0xff000000), bannerBgIntDanger: const Color(0xff461616), + bannerBgIntInfo: const Color(0xff00253d), + bannerTextIntInfo: const Color(0xffcbdbfd), bgBotBar: const Color(0xff222222), bgContextMenu: const Color(0xff262626), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), @@ -201,6 +206,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: const Color(0xff97b6fe).withValues(alpha: 0.12), btnLabelAttHigh: const Color(0xffffffff).withValues(alpha: 0.85), btnLabelAttLowIntDanger: const Color(0xffff8b7c), + btnLabelAttLowIntInfo: const Color(0xff84a8fd), btnLabelAttMediumIntDanger: const Color(0xffff8b7c), btnLabelAttMediumIntInfo: const Color(0xff97b6fe), btnShadowAttMed: const Color(0xffffffff).withValues(alpha: 0.21), @@ -252,6 +258,8 @@ class DesignVariables extends ThemeExtension { DesignVariables._({ required this.background, required this.bannerBgIntDanger, + required this.bannerBgIntInfo, + required this.bannerTextIntInfo, required this.bgBotBar, required this.bgContextMenu, required this.bgCounterUnread, @@ -266,6 +274,7 @@ class DesignVariables extends ThemeExtension { required this.btnBgAttMediumIntInfoNormal, required this.btnLabelAttHigh, required this.btnLabelAttLowIntDanger, + required this.btnLabelAttLowIntInfo, required this.btnLabelAttMediumIntDanger, required this.btnLabelAttMediumIntInfo, required this.btnShadowAttMed, @@ -318,6 +327,8 @@ class DesignVariables extends ThemeExtension { final Color background; final Color bannerBgIntDanger; + final Color bannerBgIntInfo; + final Color bannerTextIntInfo; final Color bgBotBar; final Color bgContextMenu; final Color bgCounterUnread; @@ -332,6 +343,7 @@ class DesignVariables extends ThemeExtension { final Color btnBgAttMediumIntInfoNormal; final Color btnLabelAttHigh; final Color btnLabelAttLowIntDanger; + final Color btnLabelAttLowIntInfo; final Color btnLabelAttMediumIntDanger; final Color btnLabelAttMediumIntInfo; final Color btnShadowAttMed; @@ -379,6 +391,8 @@ class DesignVariables extends ThemeExtension { DesignVariables copyWith({ Color? background, Color? bannerBgIntDanger, + Color? bannerBgIntInfo, + Color? bannerTextIntInfo, Color? bgBotBar, Color? bgContextMenu, Color? bgCounterUnread, @@ -393,6 +407,7 @@ class DesignVariables extends ThemeExtension { Color? btnBgAttMediumIntInfoNormal, Color? btnLabelAttHigh, Color? btnLabelAttLowIntDanger, + Color? btnLabelAttLowIntInfo, Color? btnLabelAttMediumIntDanger, Color? btnLabelAttMediumIntInfo, Color? btnShadowAttMed, @@ -435,6 +450,8 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: background ?? this.background, bannerBgIntDanger: bannerBgIntDanger ?? this.bannerBgIntDanger, + bannerBgIntInfo: bannerBgIntInfo ?? this.bannerBgIntInfo, + bannerTextIntInfo: bannerTextIntInfo ?? this.bannerTextIntInfo, bgBotBar: bgBotBar ?? this.bgBotBar, bgContextMenu: bgContextMenu ?? this.bgContextMenu, bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, @@ -449,6 +466,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: btnBgAttMediumIntInfoNormal ?? this.btnBgAttMediumIntInfoNormal, btnLabelAttHigh: btnLabelAttHigh ?? this.btnLabelAttHigh, btnLabelAttLowIntDanger: btnLabelAttLowIntDanger ?? this.btnLabelAttLowIntDanger, + btnLabelAttLowIntInfo: btnLabelAttLowIntInfo ?? this.btnLabelAttLowIntInfo, btnLabelAttMediumIntDanger: btnLabelAttMediumIntDanger ?? this.btnLabelAttMediumIntDanger, btnLabelAttMediumIntInfo: btnLabelAttMediumIntInfo ?? this.btnLabelAttMediumIntInfo, btnShadowAttMed: btnShadowAttMed ?? this.btnShadowAttMed, @@ -498,6 +516,8 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: Color.lerp(background, other.background, t)!, bannerBgIntDanger: Color.lerp(bannerBgIntDanger, other.bannerBgIntDanger, t)!, + bannerBgIntInfo: Color.lerp(bannerBgIntInfo, other.bannerBgIntInfo, t)!, + bannerTextIntInfo: Color.lerp(bannerTextIntInfo, other.bannerTextIntInfo, t)!, bgBotBar: Color.lerp(bgBotBar, other.bgBotBar, t)!, bgContextMenu: Color.lerp(bgContextMenu, other.bgContextMenu, t)!, bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, @@ -512,6 +532,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: Color.lerp(btnBgAttMediumIntInfoNormal, other.btnBgAttMediumIntInfoNormal, t)!, btnLabelAttHigh: Color.lerp(btnLabelAttHigh, other.btnLabelAttHigh, t)!, btnLabelAttLowIntDanger: Color.lerp(btnLabelAttLowIntDanger, other.btnLabelAttLowIntDanger, t)!, + btnLabelAttLowIntInfo: Color.lerp(btnLabelAttLowIntInfo, other.btnLabelAttLowIntInfo, t)!, btnLabelAttMediumIntDanger: Color.lerp(btnLabelAttMediumIntDanger, other.btnLabelAttMediumIntDanger, t)!, btnLabelAttMediumIntInfo: Color.lerp(btnLabelAttMediumIntInfo, other.btnLabelAttMediumIntInfo, t)!, btnShadowAttMed: Color.lerp(btnShadowAttMed, other.btnShadowAttMed, t)!, diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 295dcde7b9..0bfbd2d33a 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -143,6 +143,10 @@ extension TextEditingControllerChecks on Subject { Subject get text => has((t) => t.text, 'text'); } +extension FocusNodeChecks on Subject { + Subject get hasFocus => has((t) => t.hasFocus, 'hasFocus'); +} + extension ScrollMetricsChecks on Subject { Subject get minScrollExtent => has((x) => x.minScrollExtent, 'minScrollExtent'); Subject get maxScrollExtent => has((x) => x.maxScrollExtent, 'maxScrollExtent'); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 7ab166bc44..ef7b8e64b3 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -23,6 +23,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/action_sheet.dart'; import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/emoji.dart'; @@ -52,11 +53,18 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + bool? realmAllowMessageEditing, + int? realmMessageContentEditLimitSeconds, }) async { addTearDown(testBinding.reset); assert(narrow.containsMessage(message)); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add( + eg.selfAccount, + eg.initialSnapshot( + realmAllowMessageEditing: realmAllowMessageEditing, + realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + )); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([ eg.selfUser, @@ -1432,6 +1440,169 @@ void main() { }); }); + group('EditButton', () { + Future tapEdit(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.edit, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.edit)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + group('present/absent appropriately', () { + /// Test whether the edit-message button is visible, given params. + /// + /// The message timestamp is 60s before the current time + /// ([TestZulipBinding.utcNow]) as of the start of the test run. + /// + /// The message has streamId: 1 and topic: 'topic'. + /// The message list is for that [TopicNarrow] unless [narrow] is passed. + void testVisibility(bool expected, { + bool self = true, + Narrow? narrow, + bool allowed = true, + int? limit, + bool boxInEditMode = false, + bool? errorStatus, + bool poll = false, + }) { + // It's inconvenient here to set up a state where the compose box + // is in edit mode and the action sheet is opened for a message + // with an edit request that's in progress or in the error state. + // In the setup, we'd need to either use two messages or (via an edge + // case) two MessageListPages. It should suffice to test the + // boxInEditMode and errorStatus states separately. + assert(!boxInEditMode || errorStatus == null); + + final description = [ + 'from self: $self', + 'narrow: $narrow', + 'realm allows: $allowed', + 'edit limit: $limit', + 'compose box is in editing mode: $boxInEditMode', + 'edit-message error status: $errorStatus', + 'has poll: $poll', + ].join(', '); + + void checkButtonIsPresent(bool expected) { + if (expected) { + check(find.byIcon(ZulipIcons.edit, skipOffstage: false)).findsOne(); + } else { + check(find.byIcon(ZulipIcons.edit, skipOffstage: false)).findsNothing(); + } + } + + testWidgets(description, (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final message = eg.streamMessage( + stream: eg.stream(streamId: 1), + topic: 'topic', + sender: self ? eg.selfUser : eg.otherUser, + timestamp: eg.utcTimestamp(testBinding.utcNow()) - 60, + submessages: poll + ? [eg.submessage(content: eg.pollWidgetData(question: 'poll', options: ['A']))] + : null, + ); + + await setupToMessageActionSheet(tester, + message: message, + narrow: narrow ?? TopicNarrow.ofMessage(message), + realmAllowMessageEditing: allowed, + realmMessageContentEditLimitSeconds: limit, + ); + + if (!boxInEditMode && errorStatus == null) { + // The state we're testing is present on the original action sheet. + checkButtonIsPresent(expected); + return; + } + // The state we're testing requires a previous "edit message" action + // in order to set up. Use the first action sheet for that setup step. + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson()); + await tapEdit(tester); + await tester.pump(Duration.zero); + await tester.enterText(find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller?.text == 'foo'), + 'bar'); + + if (errorStatus == true) { + // We're testing the request-failed state. Prepare a failure + // and tap Save. + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + } else if (errorStatus == false) { + // We're testing the request-in-progress state. Prepare a delay, + // tap Save, and wait through only part of the delay. + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration(milliseconds: 500)); + } else { + // We're testing the state where the compose box is in + // edit-message mode. Keep it that way by not tapping Save. + } + + // See comment in setupToMessageActionSheet about warnIfMissed: false + await tester.longPress(find.byType(MessageContent), warnIfMissed: false); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + check(find.byType(BottomSheet)).findsOne(); + checkButtonIsPresent(expected); + + await tester.pump(Duration(milliseconds: 500)); // flush timers + }); + } + + testVisibility(true); + // TODO(server-6) limit 0 not expected on 6.0+ + testVisibility(true, limit: 0); + testVisibility(true, limit: 600); + testVisibility(true, narrow: ChannelNarrow(1)); + + testVisibility(false, self: false); + testVisibility(false, narrow: CombinedFeedNarrow()); + testVisibility(false, allowed: false); + testVisibility(false, limit: 10); + testVisibility(false, boxInEditMode: true); + testVisibility(false, errorStatus: false); + testVisibility(false, errorStatus: true); + testVisibility(false, poll: true); + }); + + group('tap button', () { + ComposeBoxController? findComposeBoxController(WidgetTester tester) { + return tester.stateList(find.byType(ComposeBox)) + .singleOrNull?.controller; + } + + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(sender: eg.selfUser); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + realmAllowMessageEditing: true, + realmMessageContentEditLimitSeconds: null, + ); + + check(findComposeBoxController(tester)) + .isA(); + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson()); + await tapEdit(tester); + await tester.pump(Duration.zero); + + check(findComposeBoxController(tester)) + .isA() + ..messageId.equals(message.id) + ..originalRawContent.equals('foo'); + }); + }); + }); + group('MessageActionSheetCancelButton', () { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart index 8008b510d3..b93ff7f1bf 100644 --- a/test/widgets/compose_box_checks.dart +++ b/test/widgets/compose_box_checks.dart @@ -1,6 +1,21 @@ import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.dart'; import 'package:zulip/widgets/compose_box.dart'; +extension ComposeBoxStateChecks on Subject { + Subject get controller => has((c) => c.controller, 'controller'); +} + +extension ComposeBoxControllerChecks on Subject { + Subject get content => has((c) => c.content, 'content'); + Subject get contentFocusNode => has((c) => c.contentFocusNode, 'contentFocusNode'); +} + +extension EditMessageComposeBoxControllerChecks on Subject { + Subject get messageId => has((c) => c.messageId, 'messageId'); + Subject get originalRawContent => has((c) => c.originalRawContent, 'originalRawContent'); +} + extension ComposeContentControllerChecks on Subject { Subject> get validationErrors => has((c) => c.validationErrors, 'validationErrors'); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 24c4d403b3..679f4de190 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,11 +3,12 @@ import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:crypto/crypto.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker/image_picker.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; @@ -18,6 +19,7 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -33,6 +35,7 @@ import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../model/typing_status_test.dart'; import '../stdlib_checks.dart'; +import 'compose_box_checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -41,6 +44,10 @@ void main() { late PerAccountStore store; late FakeApiConnection connection; + late ComposeBoxState state; + + // Caution: when testing edit-message UI, this will often be stale; + // read state.controller instead. late ComposeBoxController? controller; Future prepareComposeBox(WidgetTester tester, { @@ -64,6 +71,8 @@ void main() { streams: streams, zulipFeatureLevel: zulipFeatureLevel, realmMandatoryTopics: mandatoryTopics, + realmAllowMessageEditing: true, + realmMessageContentEditLimitSeconds: null, )); store = await testBinding.globalStore.perAccount(selfAccount.id); @@ -77,7 +86,8 @@ void main() { await tester.pumpAndSettle(); connection.takeRequests(); - controller = tester.state(find.byType(ComposeBox)).controller; + state = tester.state(find.byType(ComposeBox)); + controller = state.controller; } /// A [Finder] for the topic input. @@ -232,6 +242,33 @@ void main() { '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); }); }); + + group('ContentValidationError.empty', () { + late ComposeContentController controller; + + void checkCountsAsEmpty(String text, bool expected) { + controller.value = TextEditingValue(text: text); + expected + ? check(controller).validationErrors.contains(ContentValidationError.empty) + : check(controller).validationErrors.not((it) => it.contains(ContentValidationError.empty)); + } + + testWidgets('requireNotEmpty: true (default)', (tester) async { + controller = ComposeContentController(); + addTearDown(controller.dispose); + checkCountsAsEmpty('', true); + checkCountsAsEmpty(' ', true); + checkCountsAsEmpty('a', false); + }); + + testWidgets('requireNotEmpty: false', (tester) async { + controller = ComposeContentController(requireNotEmpty: false); + addTearDown(controller.dispose); + checkCountsAsEmpty('', false); + checkCountsAsEmpty(' ', false); + checkCountsAsEmpty('a', false); + }); + }); }); group('length validation', () { @@ -1363,4 +1400,468 @@ void main() { checkContentInputValue(tester, 'some content'); }); }); + + group('edit message', () { + final channel = eg.stream(); + final topic = 'topic'; + final message = eg.streamMessage(sender: eg.selfUser, stream: channel, topic: topic); + final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + + final channelNarrow = ChannelNarrow(channel.streamId); + final topicNarrow = eg.topicNarrow(channel.streamId, topic); + final dmNarrow = DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId); + + Message msgInNarrow(Narrow narrow) { + final List messages = [message, dmMessage]; + return messages.where((m) => narrow.containsMessage(m)).single; + } + + int msgIdInNarrow(Narrow narrow) => msgInNarrow(narrow).id; + + Future prepareEditMessage(WidgetTester tester, {required Narrow narrow}) async { + await prepareComposeBox(tester, + narrow: narrow, + streams: [channel]); + await store.addMessages([message, dmMessage]); + await tester.pump(); // message list updates + } + + /// Check that the compose box is in the "Preparing…" state, + /// awaiting the fetch-raw-content request. + Future checkAwaitingRawMessageContent(WidgetTester tester) async { + check(state.controller) + .isA() + ..originalRawContent.isNull() + ..content.value.text.equals(''); + check(tester.widget(contentInputFinder)) + .isA() + .decoration.isNotNull().hintText.equals('Preparing…'); + checkContentInputValue(tester, ''); + + // Controls are disabled + await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false); + await tester.pump(); + check(testBinding.takePickFilesCalls()).isEmpty(); + + // Save button is disabled + final lastRequest = connection.lastRequest; + await tester.tap( + find.widgetWithText(ZulipWebUiKitButton, 'Save'), warnIfMissed: false); + await tester.pump(Duration.zero); + check(connection.lastRequest).equals(lastRequest); + } + + /// Starts an interaction from the action sheet's 'Edit message' button. + /// + /// The fetch-raw-content request is prepared with [delay] (default 1s). + Future startInteractionFromActionSheet( + WidgetTester tester, { + required int messageId, + String originalRawContent = 'foo', + Duration delay = const Duration(seconds: 1), + bool fetchShouldSucceed = true, + }) async { + await tester.longPress(find.byWidgetPredicate((widget) => + widget is MessageWithPossibleSender && widget.item.message.id == messageId)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + final findEditButton = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.edit, skipOffstage: false)); + await tester.ensureVisible(findEditButton); + if (fetchShouldSucceed) { + connection.prepare(delay: delay, + json: GetMessageResult(message: eg.streamMessage(content: originalRawContent)).toJson()); + } else { + connection.prepare(apiException: eg.apiBadRequest(), delay: delay); + } + await tester.tap(findEditButton); + await tester.pump(); + await tester.pump(); + connection.takeRequests(); + } + + /// Starts an interaction by tapping a failed edit in the message list. + Future startInteractionFromRestoreFailedEdit( + WidgetTester tester, { + required int messageId, + String originalRawContent = 'foo', + String newContent = 'bar', + }) async { + await startInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: originalRawContent); + await tester.pump(Duration(seconds: 1)); // raw-content request + await enterContent(tester, newContent); + + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + await tester.tap(find.text('EDIT NOT SAVED')); + await tester.pump(); + connection.takeRequests(); + } + + void checkRequest(int messageId, { + required String prevContent, + required String content, + }) { + final prevContentSha256 = sha256.convert(utf8.encode(prevContent)).toString(); + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'prev_content_sha256': prevContentSha256, + 'content': content, + }); + } + + /// Check that the compose box is not in editing mode. + void checkNotInEditingMode(WidgetTester tester, { + required Narrow narrow, + String expectedContentText = '', + }) { + switch (narrow) { + case ChannelNarrow(): + check(state.controller) + .isA() + .content.value.text.equals(expectedContentText); + case TopicNarrow(): + case DmNarrow(): + check(state.controller) + .isA() + .content.value.text.equals(expectedContentText); + default: + throw StateError('unexpected narrow type'); + } + checkContentInputValue(tester, expectedContentText); + } + + void testSmoke({required Narrow narrow, required _EditInteractionStart start}) { + testWidgets('smoke: $narrow, ${start.message()}', (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + switch (start) { + case _EditInteractionStart.actionSheet: + await startInteractionFromActionSheet(tester, + messageId: messageId, + originalRawContent: 'foo'); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkContentInputValue(tester, 'foo'); + case _EditInteractionStart.restoreFailedEdit: + await startInteractionFromRestoreFailedEdit(tester, + messageId: messageId, + originalRawContent: 'foo', + newContent: 'bar'); + checkContentInputValue(tester, 'bar'); + } + + // Now that we have the raw content, check the input is interactive + // but no typing notifications are sent… + check(TypingNotifier.debugEnable).isTrue(); + check(state).controller.contentFocusNode.hasFocus.isTrue(); + await enterContent(tester, 'some new content'); + check(connection.takeRequests()).isEmpty(); + + // …and the upload buttons work. + testBinding.pickFilesResult = FilePickerResult([ + PlatformFile(name: 'file.jpg', size: 1000, readStream: Stream.fromIterable(['asdf'.codeUnits]))]); + connection.prepare(json: + UploadFileResult(uri: '/path/file.jpg').toJson()); + await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false); + await tester.pump(Duration.zero); + checkNoErrorDialog(tester); + check(testBinding.takePickFilesCalls()).length.equals(1); + connection.takeRequests(); // upload request + + // TODO could also check that quote-and-reply and autocomplete work + // (but as their own test cases, for a single narrow and start) + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, + prevContent: 'foo', content: 'some new content[file.jpg](/path/file.jpg)'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + testSmoke(narrow: channelNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: topicNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: dmNarrow, start: _EditInteractionStart.actionSheet); + testSmoke(narrow: channelNarrow, start: _EditInteractionStart.restoreFailedEdit); + testSmoke(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); + testSmoke(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); + + Future expectAndHandleDiscardConfirmation( + WidgetTester tester, { + required bool shouldContinue, + }) async { + final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Discard the message you’re writing?', + expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', + expectedActionButtonText: 'Discard'); + if (shouldContinue) { + await tester.tap(find.byWidget(actionButton)); + } else { + await tester.tap(find.byWidget(cancelButton)); + } + } + + // Test the "Discard…?" confirmation dialog when you tap "Edit message" in + // the action sheet but there's text in the compose box for a new message. + void testInterruptComposingFromActionSheet({required Narrow narrow}) { + testWidgets('interrupting new-message compose: $narrow', (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final messageId = msgIdInNarrow(narrow); + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + await enterContent(tester, 'composing new message'); + + // Expect confirmation dialog; tap Cancel + await startInteractionFromActionSheet(tester, messageId: messageId); + await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); + check(connection.takeRequests()).isEmpty(); + // fetch-raw-content request wasn't actually sent; + // take back its prepared response + connection.clearPreparedResponses(); + + // Twiddle the input to make sure it still works + checkNotInEditingMode(tester, + narrow: narrow, expectedContentText: 'composing new message'); + await enterContent(tester, 'composing new message…'); + checkContentInputValue(tester, 'composing new message…'); + + // Try again, but this time tap Discard and expect to enter an edit session + await startInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); + await tester.pump(); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + check(connection.takeRequests()).length.equals(1); + checkContentInputValue(tester, 'foo'); + await enterContent(tester, 'bar'); + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'bar'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // Cover multiple narrows, checking that the Discard button resets the state + // correctly for each one. + testInterruptComposingFromActionSheet(narrow: channelNarrow); + testInterruptComposingFromActionSheet(narrow: topicNarrow); + testInterruptComposingFromActionSheet(narrow: dmNarrow); + + // Test the "Discard…?" confirmation dialog when you want to restore + // a failed edit but there's text in the compose box for a new message. + void testInterruptComposingFromFailedEdit({required Narrow narrow}) { + testWidgets('interrupting new-message compose by tapping failed edit to restore: $narrow', (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final messageId = msgIdInNarrow(narrow); + await prepareEditMessage(tester, narrow: narrow); + + await startInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await tester.pump(Duration(seconds: 1)); // raw-content request + await enterContent(tester, 'bar'); + + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + connection.takeRequests(); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + check(find.text('EDIT NOT SAVED')).findsOne(); + + await enterContent(tester, 'composing new message'); + + // Expect confirmation dialog; tap Cancel + await tester.tap(find.text('EDIT NOT SAVED')); + await tester.pump(); + await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); + checkNotInEditingMode(tester, + narrow: narrow, expectedContentText: 'composing new message'); + + // Twiddle the input to make sure it still works + await enterContent(tester, 'composing new message…'); + + // Try again, but this time tap Discard and expect to enter edit session + await tester.tap(find.text('EDIT NOT SAVED')); + await tester.pump(); + await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); + await tester.pump(); + checkContentInputValue(tester, 'bar'); + await enterContent(tester, 'baz'); + + // Save; check that the request is made and the compose box resets. + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'baz'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // (So tests run faster, skip some narrows that are already covered above.) + testInterruptComposingFromFailedEdit(narrow: channelNarrow); + // testInterruptComposingFromFailedEdit(narrow: topicNarrow); + // testInterruptComposingFromFailedEdit(narrow: dmNarrow); + + // TODO also test: + // - Restore a failed edit, but when there's compose input for an edit- + // message session. (The failed edit would be for a different message, + // or else started from a different MessageListPage.) + + void testFetchRawContentFails({required Narrow narrow}) { + final description = 'fetch-raw-content fails: $narrow'; + testWidgets(description, (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + await startInteractionFromActionSheet(tester, + messageId: messageId, + originalRawContent: 'foo', + fetchShouldSucceed: false); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkErrorDialog(tester, expectedTitle: 'Could not edit message'); + checkNotInEditingMode(tester, narrow: narrow); + }); + } + // Skip some narrows so the tests run faster; + // the codepaths to be tested are basically the same. + // testFetchRawContentFails(narrow: channelNarrow); + testFetchRawContentFails(narrow: topicNarrow); + // testFetchRawContentFails(narrow: dmNarrow); + + /// Test that an edit session is really cleared by the Cancel button. + /// + /// If `start: _EditInteractionStart.actionSheet` (the default), + /// pass duringFetchRawContentRequest to control whether the Cancel button + /// is tapped during (true) or after (false) the fetch-raw-content request. + /// + /// If `start: _EditInteractionStart.restoreFailedEdit`, + /// don't pass duringFetchRawContentRequest. + void testCancel({ + required Narrow narrow, + _EditInteractionStart start = _EditInteractionStart.actionSheet, + bool? duringFetchRawContentRequest, + }) { + final description = StringBuffer()..write('tap Cancel '); + switch (start) { + case _EditInteractionStart.actionSheet: + assert(duringFetchRawContentRequest != null); + description + ..write(duringFetchRawContentRequest! ? 'during ' : 'after ') + ..write('fetch-raw-content request: '); + case _EditInteractionStart.restoreFailedEdit: + assert(duringFetchRawContentRequest == null); + description.write('when editing from a restored failed edit: '); + } + description.write('$narrow'); + testWidgets(description.toString(), (tester) async { + await prepareEditMessage(tester, narrow: narrow); + checkNotInEditingMode(tester, narrow: narrow); + + final messageId = msgIdInNarrow(narrow); + switch (start) { + case _EditInteractionStart.actionSheet: + await startInteractionFromActionSheet(tester, + messageId: messageId, delay: Duration(seconds: 5)); + await checkAwaitingRawMessageContent(tester); + await tester.pump(duringFetchRawContentRequest! + ? Duration(milliseconds: 500) + : Duration(seconds: 5)); + case _EditInteractionStart.restoreFailedEdit: + await startInteractionFromRestoreFailedEdit(tester, + messageId: messageId, + newContent: 'bar'); + checkContentInputValue(tester, 'bar'); + } + + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Cancel')); + await tester.pump(); + checkNotInEditingMode(tester, narrow: narrow); + + // We've canceled the previous edit session, so we should be able to + // do a new edit-message session… + await startInteractionFromActionSheet(tester, + messageId: messageId, originalRawContent: 'foo'); + await checkAwaitingRawMessageContent(tester); + await tester.pump(Duration(seconds: 1)); // fetch-raw-content request + checkContentInputValue(tester, 'foo'); + await enterContent(tester, 'qwerty'); + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + checkRequest(messageId, prevContent: 'foo', content: 'qwerty'); + await tester.pump(Duration.zero); + checkNotInEditingMode(tester, narrow: narrow); + + // …or send a new message. + connection.prepare(json: {}); // for typing-start request + connection.prepare(json: {}); // for typing-stop request + await enterContent(tester, 'new message to send'); + state.controller.contentFocusNode.unfocus(); + await tester.pump(); + check(connection.takeRequests()).deepEquals(>[ + (it) => it.isA() + ..method.equals('POST')..url.path.equals('/api/v1/typing'), + (it) => it.isA() + ..method.equals('POST')..url.path.equals('/api/v1/typing')]); + if (narrow is ChannelNarrow) { + await enterTopic(tester, narrow: narrow, topic: topic); + } + await tester.pump(); + await tapSendButton(tester); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages'); + checkContentInputValue(tester, ''); + + if (start == _EditInteractionStart.actionSheet && duringFetchRawContentRequest!) { + // Await the fetch-raw-content request from the canceled edit session; + // its completion shouldn't affect anything. + await tester.pump(Duration(seconds: 5)); + } + checkNotInEditingMode(tester, narrow: narrow); + check(connection.takeRequests()).isEmpty(); + }); + } + // Skip some narrows so the tests run faster; + // the codepaths to be tested are basically the same. + testCancel(narrow: channelNarrow, duringFetchRawContentRequest: false); + // testCancel(narrow: topicNarrow, duringFetchRawContentRequest: false); + testCancel(narrow: dmNarrow, duringFetchRawContentRequest: false); + // testCancel(narrow: channelNarrow, duringFetchRawContentRequest: true); + testCancel(narrow: topicNarrow, duringFetchRawContentRequest: true); + // testCancel(narrow: dmNarrow, duringFetchRawContentRequest: true); + testCancel(narrow: channelNarrow, start: _EditInteractionStart.restoreFailedEdit); + // testCancel(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); + // testCancel(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); + }); +} + +/// How the edit interaction is started: +/// from the action sheet, or by restoring a failed edit. +enum _EditInteractionStart { + actionSheet, + restoreFailedEdit; + + String message() { + return switch (this) { + _EditInteractionStart.actionSheet => 'from action sheet', + _EditInteractionStart.restoreFailedEdit => 'from restoring a failed edit', + }; + } } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f4de7b54ae..69a7204827 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -20,6 +20,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/color.dart'; +import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -36,6 +37,7 @@ import '../flutter_checks.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; +import 'compose_box_checks.dart'; import 'content_checks.dart'; import 'dialog_checks.dart'; import 'message_list_checks.dart'; @@ -1424,7 +1426,7 @@ void main() { }); }); - group('edit state label', () { + group('EDITED/MOVED label and edit-message error status', () { void checkMarkersCount({required int edited, required int moved}) { check(find.text('EDITED').evaluate()).length.equals(edited); check(find.text('MOVED').evaluate()).length.equals(moved); @@ -1455,6 +1457,81 @@ void main() { await tester.pump(); checkMarkersCount(edited: 2, moved: 0); }); + + void checkEditInProgress(WidgetTester tester) { + check(find.text('SAVING EDIT…')).findsOne(); + check(find.byType(LinearProgressIndicator)).findsOne(); + final opacityWidget = tester.widget(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))); + check(opacityWidget.opacity).equals(0.6); + checkMarkersCount(edited: 0, moved: 0); + } + + void checkEditNotInProgress(WidgetTester tester) { + check(find.text('SAVING EDIT…')).findsNothing(); + check(find.byType(LinearProgressIndicator)).findsNothing(); + check(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))).findsNothing(); + } + + void checkEditFailed(WidgetTester tester) { + check(find.text('EDIT NOT SAVED')).findsOne(); + final opacityWidget = tester.widget(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))); + check(opacityWidget.opacity).equals(0.6); + checkMarkersCount(edited: 0, moved: 0); + } + + testWidgets('successful edit', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + messages: [message]); + + connection.prepare(json: UpdateMessageResult().toJson()); + store.editMessage(messageId: message.id, + originalRawContent: 'foo', + newContent: 'bar'); + await tester.pump(Duration.zero); + checkEditInProgress(tester); + await store.handleEvent(eg.updateMessageEditEvent(message)); + await tester.pump(); + checkEditNotInProgress(tester); + }); + + testWidgets('failed edit', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + messages: [message]); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'foo', + newContent: 'bar'); + await tester.pump(Duration.zero); + checkEditInProgress(tester); + await tester.pump(Duration(seconds: 1)); + checkEditFailed(tester); + + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson(), delay: Duration(milliseconds: 500)); + await tester.tap(find.byType(MessageContent)); + // We don't clear out the failed attempt, with the intended new content… + checkEditFailed(tester); + await tester.pump(Duration(milliseconds: 500)); + // …until we have the current content, from a successful message fetch, + // for prevContentSha256. + checkEditNotInProgress(tester); + + final state = MessageListPage.ancestorOf(tester.element(find.byType(MessageContent))); + check(state.composeBoxState).isNotNull().controller + .isA() + .content.value.text.equals('bar'); + }); }); group('_UnreadMarker animations', () { From bacf1de9e4f542e12cf4ce66a65c917974f7f3c9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 5 May 2025 16:48:49 -0700 Subject: [PATCH 049/290] msglist test: Ensure later errors get reported in full too Otherwise, if several different test cases in this file fail due to checks failing inside checkInvariants, then only the first one gets reported in detail with the comparison and the stack trace. --- test/model/message_list_test.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index bb85556ae9..9357beb19d 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/backoff.dart'; @@ -27,6 +28,24 @@ const newestResult = eg.newestGetMessagesResult; const olderResult = eg.olderGetMessagesResult; void main() { + // Arrange for errors caught within the Flutter framework to be printed + // unconditionally, rather than throttled as they normally are in an app. + // + // When using `testWidgets` from flutter_test, this is done automatically; + // compare the [FlutterError.dumpErrorToConsole] call sites, + // and [FlutterError.onError=] and [debugPrint=] call sites, in flutter_test. + // + // This test file is unusual in needing this manual arrangement; it's needed + // because these aren't widget tests, and yet do have some failures arise as + // exceptions that get caught by the framework: namely, when [checkInvariants] + // throws from within an `addListener` callback. Those exceptions get caught + // by [ChangeNotifier.notifyListeners] and reported there through + // [FlutterError.reportError]. + debugPrint = debugPrintSynchronously; + FlutterError.onError = (details) { + FlutterError.dumpErrorToConsole(details, forceReport: true); + }; + // These variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; From 4d6948a9e0f1d3b46d448013328f29932400fa94 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 5 May 2025 19:39:30 -0700 Subject: [PATCH 050/290] msglist test [nfc]: Use checkHasMessages/MessageIds helpers throughout --- test/model/message_list_test.dart | 72 +++++++++++++------------------ 1 file changed, 31 insertions(+), 41 deletions(-) diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 9357beb19d..8d47d61ff7 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -116,6 +116,14 @@ void main() { }); } + void checkHasMessageIds(Iterable messageIds) { + check(model.messages.map((m) => m.id)).deepEquals(messageIds); + } + + void checkHasMessages(Iterable messages) { + checkHasMessageIds(messages.map((e) => e.id)); + } + group('fetchInitial', () { final someChannel = eg.stream(); const someTopic = 'some topic'; @@ -439,10 +447,6 @@ void main() { await setVisibility(policy); } - void checkHasMessageIds(Iterable messageIds) { - check(model.messages.map((m) => m.id)).deepEquals(messageIds); - } - test('mute a visible topic', () async { await prepare(narrow: const CombinedFeedNarrow()); await prepareMutes(); @@ -643,11 +647,11 @@ void main() { check(model).messages.length.equals(30); await store.handleEvent(eg.deleteMessageEvent(messagesToDelete)); checkNotifiedOnce(); - check(model.messages.map((message) => message.id)).deepEquals([ + checkHasMessages([ ...messages.sublist(0, 2), ...messages.sublist(5, 10), ...messages.sublist(15), - ].map((message) => message.id)); + ]); }); }); @@ -750,10 +754,6 @@ void main() { final stream = eg.stream(); final otherStream = eg.stream(); - void checkHasMessages(Iterable messages) { - check(model.messages.map((e) => e.id)).deepEquals(messages.map((e) => e.id)); - } - Future prepareNarrow(Narrow narrow, List? messages) async { await prepare(narrow: narrow); for (final streamToAdd in [stream, otherStream]) { @@ -1457,8 +1457,7 @@ void main() { eg.dmMessage( id: 205, from: eg.otherUser, to: [eg.selfUser]), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 203, 205])); + checkHasMessageIds(expected..addAll([201, 203, 205])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1471,34 +1470,33 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 103, 105])); + checkHasMessageIds(expected..insertAll(0, [101, 103, 105])); // … and on MessageEvent. await store.addMessage( eg.streamMessage(id: 301, stream: stream1, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); await store.addMessage( eg.streamMessage(id: 302, stream: stream1, topic: 'B')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); await store.addMessage( eg.streamMessage(id: 303, stream: stream2, topic: 'C')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(303)); + checkHasMessageIds(expected..add(303)); await store.addMessage( eg.streamMessage(id: 304, stream: stream2, topic: 'D')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); await store.addMessage( eg.dmMessage(id: 305, from: eg.otherUser, to: [eg.selfUser])); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(305)); + checkHasMessageIds(expected..add(305)); }); test('in ChannelNarrow', () async { @@ -1516,8 +1514,7 @@ void main() { eg.streamMessage(id: 203, stream: stream, topic: 'C'), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202])); + checkHasMessageIds(expected..addAll([201, 202])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1528,24 +1525,23 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102])); + checkHasMessageIds(expected..insertAll(0, [101, 102])); // … and on MessageEvent. await store.addMessage( eg.streamMessage(id: 301, stream: stream, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); await store.addMessage( eg.streamMessage(id: 302, stream: stream, topic: 'B')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(302)); + checkHasMessageIds(expected..add(302)); await store.addMessage( eg.streamMessage(id: 303, stream: stream, topic: 'C')); checkNotNotified(); - check(model.messages.map((m) => m.id)).deepEquals(expected); + checkHasMessageIds(expected); }); test('in TopicNarrow', () async { @@ -1560,8 +1556,7 @@ void main() { eg.streamMessage(id: 201, stream: stream, topic: 'A'), ]); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201])); + checkHasMessageIds(expected..addAll([201])); // … and on fetchOlder… connection.prepare(json: olderResult( @@ -1570,14 +1565,13 @@ void main() { ]).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101])); + checkHasMessageIds(expected..insertAll(0, [101])); // … and on MessageEvent. await store.addMessage( eg.streamMessage(id: 301, stream: stream, topic: 'A')); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301)); + checkHasMessageIds(expected..add(301)); }); test('in MentionsNarrow', () async { @@ -1600,23 +1594,21 @@ void main() { // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: getMessages(201)); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202, 203])); + checkHasMessageIds(expected..addAll([201, 202, 203])); // … and on fetchOlder… connection.prepare(json: olderResult( anchor: 201, foundOldest: true, messages: getMessages(101)).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102, 103])); + checkHasMessageIds(expected..insertAll(0, [101, 102, 103])); // … and on MessageEvent. final messages = getMessages(301); for (var i = 0; i < 3; i += 1) { await store.addMessage(messages[i]); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i)); + checkHasMessageIds(expected..add(301 + i)); } }); @@ -1638,23 +1630,21 @@ void main() { // Check filtering on fetchInitial… await prepareMessages(foundOldest: false, messages: getMessages(201)); final expected = []; - check(model.messages.map((m) => m.id)) - .deepEquals(expected..addAll([201, 202])); + checkHasMessageIds(expected..addAll([201, 202])); // … and on fetchOlder… connection.prepare(json: olderResult( anchor: 201, foundOldest: true, messages: getMessages(101)).toJson()); await model.fetchOlder(); checkNotified(count: 2); - check(model.messages.map((m) => m.id)) - .deepEquals(expected..insertAll(0, [101, 102])); + checkHasMessageIds(expected..insertAll(0, [101, 102])); // … and on MessageEvent. final messages = getMessages(301); for (var i = 0; i < 2; i += 1) { await store.addMessage(messages[i]); checkNotifiedOnce(); - check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i)); + checkHasMessageIds(expected..add(301 + i)); } }); }); From acf8376087728345d93913c24aa11dd834e35f01 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 16:49:20 -0700 Subject: [PATCH 051/290] msglist [nfc]: Expand doc on MessageListView.messages --- lib/model/message_list.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 2f8777d3ed..fd171938c4 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -70,7 +70,11 @@ mixin _MessageSequence { /// A sequence number for invalidating stale fetches. int generation = 0; - /// The messages. + /// The known messages in the list. + /// + /// This may or may not represent all the message history that + /// conceptually belongs in this message list. + /// That information is expressed in [fetched] and [haveOldest]. /// /// See also [contents] and [items]. final List messages = []; From 5709cc19a1c0d351bb259e2934ded665500702c6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 16:58:09 -0700 Subject: [PATCH 052/290] msglist [nfc]: Move sliver boundary into the view-model This will allow the model to maintain it over time as newer messages arrive or get fetched. --- lib/model/message_list.dart | 9 +++++++++ lib/widgets/message_list.dart | 5 ++--- test/model/message_list_test.dart | 5 +++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index fd171938c4..85881f4dca 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -137,8 +137,17 @@ mixin _MessageSequence { /// This information is completely derived from [messages] and /// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown]. /// It exists as an optimization, to memoize that computation. + /// + /// See also [middleItem], an index which divides this list + /// into a top slice and a bottom slice. final QueueList items = QueueList(); + /// An index into [items] dividing it into a top slice and a bottom slice. + /// + /// The indices 0 to before [middleItem] are the top slice of [items], + /// and the indices from [middleItem] to the end are the bottom slice. + int get middleItem => items.isEmpty ? 0 : items.length - 1; + int _findMessageWithId(int messageId) { return binarySearchByKey(messages, messageId, (message, messageId) => message.id.compareTo(messageId)); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 72f646cc65..dcd063a62a 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -573,10 +573,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat // The list has two slivers: a top sliver growing upward, // and a bottom sliver growing downward. // Each sliver has some of the items from `model.items`. - const maxBottomItems = 1; final totalItems = model.items.length; - final bottomItems = totalItems <= maxBottomItems ? totalItems : maxBottomItems; - final topItems = totalItems - bottomItems; + final topItems = model.middleItem; + final bottomItems = totalItems - topItems; // The top sliver has its child 0 as the item just before the // sliver boundary, child 1 as the item before that, and so on. diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 8d47d61ff7..bf626d77d0 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1997,6 +1997,10 @@ void checkInvariants(MessageListView model) { }); } check(model.items).length.equals(i); + + check(model).middleItem + ..isGreaterOrEqual(0) + ..isLessOrEqual(model.items.length); } extension MessageListRecipientHeaderItemChecks on Subject { @@ -2024,6 +2028,7 @@ extension MessageListViewChecks on Subject { Subject> get messages => has((x) => x.messages, 'messages'); Subject> get contents => has((x) => x.contents, 'contents'); Subject> get items => has((x) => x.items, 'items'); + Subject get middleItem => has((x) => x.middleItem, 'middleItem'); Subject get fetched => has((x) => x.fetched, 'fetched'); Subject get haveOldest => has((x) => x.haveOldest, 'haveOldest'); Subject get fetchingOlder => has((x) => x.fetchingOlder, 'fetchingOlder'); From 36a5b8453ee5efff9fc88515ffe67a79d1232915 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 5 May 2025 17:01:46 -0700 Subject: [PATCH 053/290] msglist [nfc]: Define sliver boundary by messages This gives a bit more structured of an idea of what `middleItem` is supposed to mean. We'll use this for maintaining `middleItem` as a more dynamic value in upcoming commits. --- lib/model/message_list.dart | 17 +++++++++++++++++ test/model/message_list_test.dart | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 85881f4dca..b52413e370 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -76,9 +76,20 @@ mixin _MessageSequence { /// conceptually belongs in this message list. /// That information is expressed in [fetched] and [haveOldest]. /// + /// See also [middleMessage], an index which divides this list + /// into a top slice and a bottom slice. + /// /// See also [contents] and [items]. final List messages = []; + /// An index into [messages] dividing it into a top slice and a bottom slice. + /// + /// The indices 0 to before [middleMessage] are the top slice of [messages], + /// and the indices from [middleMessage] to the end are the bottom slice. + /// + /// The corresponding item index is [middleItem]. + int get middleMessage => messages.isEmpty ? 0 : messages.length - 1; + /// Whether [messages] and [items] represent the results of a fetch. /// /// This allows the UI to distinguish "still working on fetching messages" @@ -146,6 +157,12 @@ mixin _MessageSequence { /// /// The indices 0 to before [middleItem] are the top slice of [items], /// and the indices from [middleItem] to the end are the bottom slice. + /// + /// The top and bottom slices of [items] correspond to + /// the top and bottom slices of [messages] respectively. + /// Either the bottom slices of both [items] and [messages] are empty, + /// or the first item in the bottom slice of [items] is a [MessageListMessageItem] + /// for the first message in the bottom slice of [messages]. int get middleItem => items.isEmpty ? 0 : items.length - 1; int _findMessageWithId(int messageId) { diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index bf626d77d0..94446ca241 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1960,6 +1960,10 @@ void checkInvariants(MessageListView model) { check(isSortedWithoutDuplicates(model.messages.map((m) => m.id).toList())) .isTrue(); + check(model).middleMessage + ..isGreaterOrEqual(0) + ..isLessOrEqual(model.messages.length); + check(model).contents.length.equals(model.messages.length); for (int i = 0; i < model.contents.length; i++) { final poll = model.messages[i].poll; @@ -2001,6 +2005,12 @@ void checkInvariants(MessageListView model) { check(model).middleItem ..isGreaterOrEqual(0) ..isLessOrEqual(model.items.length); + if (model.middleItem == model.items.length) { + check(model.middleMessage).equals(model.messages.length); + } else { + check(model.items[model.middleItem]).isA() + .message.identicalTo(model.messages[model.middleMessage]); + } } extension MessageListRecipientHeaderItemChecks on Subject { @@ -2026,6 +2036,7 @@ extension MessageListViewChecks on Subject { Subject get store => has((x) => x.store, 'store'); Subject get narrow => has((x) => x.narrow, 'narrow'); Subject> get messages => has((x) => x.messages, 'messages'); + Subject get middleMessage => has((x) => x.middleMessage, 'middleMessage'); Subject> get contents => has((x) => x.contents, 'contents'); Subject> get items => has((x) => x.items, 'items'); Subject get middleItem => has((x) => x.middleItem, 'middleItem'); From 091361d402ebfa2d5dc26ce6820426758c9796b9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 17:23:32 -0700 Subject: [PATCH 054/290] msglist [nfc]: Maintain middleItem as a field This new logic maintains `middleItem` according to its documented relationship with `middleMessage`. Because of the current definition of `middleMessage`, that produces the same result as the previous definition of `middleItem`. The key reasoning for why this logic works is: this touches all the code that modifies `items`, to ensure that code keeps `middleItem` up to date. And all the code which modifies `messages` (which is the only way to modify `middleMessage`) already calls `_reprocessAll` to compute `items` from scratch, except one site in `_addMessage`. Studying `_addMessage`, it also maintains `middleItem` correctly, though for that conclusion one needs the specifics of the definition of `middleMessage`. This change involves no new test code: all this logic is in scenarios well exercised by existing tests, and the invariant-checks introduced in the previous commit then effectively test this logic. To be sure of that, I also confirmed that commenting out any one of these updates to `middleItem` causes some tests to fail. --- lib/model/message_list.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index b52413e370..0b6072df6c 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -163,7 +163,7 @@ mixin _MessageSequence { /// Either the bottom slices of both [items] and [messages] are empty, /// or the first item in the bottom slice of [items] is a [MessageListMessageItem] /// for the first message in the bottom slice of [messages]. - int get middleItem => items.isEmpty ? 0 : items.length - 1; + int middleItem = 0; int _findMessageWithId(int messageId) { return binarySearchByKey(messages, messageId, @@ -295,6 +295,7 @@ mixin _MessageSequence { _fetchOlderCooldownBackoffMachine = null; contents.clear(); items.clear(); + middleItem = 0; } /// Redo all computations from scratch, based on [messages]. @@ -334,6 +335,7 @@ mixin _MessageSequence { canShareSender = (prevMessageItem.message.senderId == message.senderId); } } + if (index == middleMessage) middleItem = items.length; items.add(MessageListMessageItem(message, content, showSender: !canShareSender, isLastInBlock: true)); } @@ -344,6 +346,7 @@ mixin _MessageSequence { for (var i = 0; i < messages.length; i++) { _processMessage(i); } + if (middleMessage == messages.length) middleItem = items.length; } } From 3dfa5281119ae3d3d7288ea3b987abbefcc56179 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 17:13:28 -0700 Subject: [PATCH 055/290] msglist: Let new messages accumulate in bottom sliver This is NFC for the behavior at initial fetch. But thereafter, with this change, messages never move between slivers, and new messages go into the bottom sliver. I believe the main user-visible consequence of this change is that if the user is scrolled up in history and then a new message comes in, the new message will no longer cause all the messages to shift upward. This is the "90% solution" to #83. On the other hand, if the user is scrolled all the way to the end, then they still remain that way when a new message comes in -- there's specific logic to ensure that in MessageListScrollPosition, and an existing test in test/widgets/message_list_test.dart verifies it end to end. The main motivation for this change is that it brings us closer to having a `fetchNewer` method, and therefore to being able to have the message list start out in the middle of history. This change also allows us to revert a portion of fca651bf5, where a test had had to be weakened slightly because messages began to get moved between slivers. --- lib/model/message_list.dart | 27 +++- test/model/message_list_test.dart | 239 ++++++++++++++++++++++++++++ test/widgets/message_list_test.dart | 68 +++++--- 3 files changed, 311 insertions(+), 23 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 0b6072df6c..f2a45b78aa 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -88,7 +88,7 @@ mixin _MessageSequence { /// and the indices from [middleMessage] to the end are the bottom slice. /// /// The corresponding item index is [middleItem]. - int get middleMessage => messages.isEmpty ? 0 : messages.length - 1; + int middleMessage = 0; /// Whether [messages] and [items] represent the results of a fetch. /// @@ -232,6 +232,7 @@ mixin _MessageSequence { candidate++; assert(contents.length == messages.length); while (candidate < messages.length) { + if (candidate == middleMessage) middleMessage = target; if (test(messages[candidate])) { candidate++; continue; @@ -240,6 +241,7 @@ mixin _MessageSequence { contents[target] = contents[candidate]; target++; candidate++; } + if (candidate == middleMessage) middleMessage = target; messages.length = target; contents.length = target; assert(contents.length == messages.length); @@ -262,6 +264,13 @@ mixin _MessageSequence { } if (messagesToRemoveById.isEmpty) return false; + if (middleMessage == messages.length) { + middleMessage -= messagesToRemoveById.length; + } else { + final middleMessageId = messages[middleMessage].id; + middleMessage -= messagesToRemoveById + .where((id) => id < middleMessageId).length; + } assert(contents.length == messages.length); messages.removeWhere((message) => messagesToRemoveById.contains(message.id)); contents.removeWhere((content) => contentToRemove.contains(content)); @@ -276,11 +285,15 @@ mixin _MessageSequence { // On a Pixel 5, a batch of 100 messages takes ~15-20ms in _insertAllMessages. // (Before that, ~2-5ms in jsonDecode and 0ms in fromJson, // so skip worrying about those steps.) + final oldLength = messages.length; assert(contents.length == messages.length); messages.insertAll(index, toInsert); contents.insertAll(index, toInsert.map( (message) => _parseMessageContent(message))); assert(contents.length == messages.length); + if (index <= middleMessage) { + middleMessage += messages.length - oldLength; + } _reprocessAll(); } @@ -288,6 +301,7 @@ mixin _MessageSequence { void _reset() { generation += 1; messages.clear(); + middleMessage = 0; _fetched = false; _haveOldest = false; _fetchingOlder = false; @@ -486,13 +500,18 @@ class MessageListView with ChangeNotifier, _MessageSequence { allowEmptyTopicName: true, ); if (this.generation > generation) return; + _adjustNarrowForTopicPermalink(result.messages.firstOrNull); + store.reconcileMessages(result.messages); store.recentSenders.handleMessages(result.messages); // TODO(#824) + + // We'll make the bottom slice start at the last visible message, if any. for (final message in result.messages) { - if (_messageVisible(message)) { - _addMessage(message); - } + if (!_messageVisible(message)) continue; + middleMessage = messages.length; + _addMessage(message); + // Now [middleMessage] is the last message (the one just added). } _fetched = true; _haveOldest = result.foundOldest; diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 94446ca241..ac41d771ec 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; @@ -1649,6 +1650,214 @@ void main() { }); }); + group('middleMessage maintained', () { + // In [checkInvariants] we verify that messages don't move from the + // top to the bottom slice or vice versa. + // Most of these test cases rely on that for all the checks they need. + + test('on fetchInitial empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + await prepareMessages(foundOldest: true, messages: []); + check(model)..messages.isEmpty() + ..middleMessage.equals(0); + }); + + test('on fetchInitial empty due to muting', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream, isMuted: true)); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(stream: stream), + ]); + check(model)..messages.isEmpty() + ..middleMessage.equals(0); + }); + + test('on fetchInitial not empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream1 = eg.stream(); + final stream2 = eg.stream(); + await store.addStreams([stream1, stream2]); + await store.addSubscription(eg.subscription(stream1)); + await store.addSubscription(eg.subscription(stream2, isMuted: true)); + final messages = [ + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), + ]; + await prepareMessages(foundOldest: true, messages: messages); + // The anchor message is the last visible message… + check(model) + ..messages.length.equals(5) + ..middleMessage.equals(model.messages.length - 1) + // … even though that's not the last message that was in the response. + ..messages[model.middleMessage].id + .equals(messages[messages.length - 2].id); + }); + + /// Like [prepareMessages], but arrange for the given top and bottom slices. + Future prepareMessageSplit(List top, List bottom, { + bool foundOldest = true, + }) async { + assert(bottom.isNotEmpty); // could handle this too if necessary + await prepareMessages(foundOldest: foundOldest, messages: [ + ...top, + bottom.first, + ]); + if (bottom.length > 1) { + await store.addMessages(bottom.skip(1)); + checkNotifiedOnce(); + } + check(model) + ..messages.length.equals(top.length + bottom.length) + ..middleMessage.equals(top.length); + } + + test('on fetchOlder', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [eg.streamMessage(id: 100, stream: stream)], + [eg.streamMessage(id: 101, stream: stream)]); + + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, + messages: List.generate(5, (i) => + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + }); + + test('on fetchOlder, from top empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [], [eg.streamMessage(id: 100, stream: stream)]); + + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, + messages: List.generate(5, (i) => + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); + await model.fetchOlder(); + checkNotified(count: 2); + // The messages from fetchOlder should go in the top sliver, always. + check(model).middleMessage.equals(5); + }); + + test('on MessageEvent', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit(foundOldest: false, + [eg.streamMessage(stream: stream)], + [eg.streamMessage(stream: stream)]); + + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotifiedOnce(); + }); + + test('on messages muted, including anchor', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'bar'), + eg.streamMessage(stream: stream, topic: 'foo'), + ]); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages muted, not including anchor', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'foo'), + ]); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages muted, bottom empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await prepareMessageSplit([ + eg.streamMessage(stream: stream, topic: 'foo'), + eg.streamMessage(stream: stream, topic: 'bar'), + ], [ + eg.streamMessage(stream: stream, topic: 'third'), + ]); + + await store.handleEvent(eg.deleteMessageEvent([ + model.messages.last as StreamMessage])); + checkNotifiedOnce(); + check(model).middleMessage.equals(model.messages.length); + + await store.handleEvent(eg.userTopicEvent( + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); + checkNotifiedOnce(); + }); + + test('on messages deleted', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + final messages = [ + eg.streamMessage(id: 1, stream: stream), + eg.streamMessage(id: 2, stream: stream), + eg.streamMessage(id: 3, stream: stream), + eg.streamMessage(id: 4, stream: stream), + ]; + await prepareMessageSplit(messages.sublist(0, 2), messages.sublist(2)); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 3))); + checkNotifiedOnce(); + }); + + test('on messages deleted, bottom empty', () async { + await prepare(narrow: const CombinedFeedNarrow()); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + final messages = [ + eg.streamMessage(id: 1, stream: stream), + eg.streamMessage(id: 2, stream: stream), + eg.streamMessage(id: 3, stream: stream), + eg.streamMessage(id: 4, stream: stream), + ]; + await prepareMessageSplit(messages.sublist(0, 3), messages.sublist(3)); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(3))); + checkNotifiedOnce(); + check(model).middleMessage.equals(model.messages.length); + + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 2))); + checkNotifiedOnce(); + }); + }); + group('handle content parsing into subclasses of ZulipMessageContent', () { test('ZulipContent', () async { final stream = eg.stream(); @@ -1922,6 +2131,10 @@ void main() { }); } +MessageListView? _lastModel; +List? _lastMessages; +int? _lastMiddleMessage; + void checkInvariants(MessageListView model) { if (!model.fetched) { check(model) @@ -1964,6 +2177,21 @@ void checkInvariants(MessageListView model) { ..isGreaterOrEqual(0) ..isLessOrEqual(model.messages.length); + if (identical(model, _lastModel) + && model.generation == _lastModel!.generation) { + // All messages that were present, and still are, should be on the same side + // of `middleMessage` (still top or bottom slice respectively) as they were. + _checkNoIntersection(ListSlice(model.messages, 0, model.middleMessage), + ListSlice(_lastMessages!, _lastMiddleMessage!, _lastMessages!.length), + because: 'messages moved from bottom slice to top slice'); + _checkNoIntersection(ListSlice(_lastMessages!, 0, _lastMiddleMessage!), + ListSlice(model.messages, model.middleMessage, model.messages.length), + because: 'messages moved from top slice to bottom slice'); + } + _lastModel = model; + _lastMessages = model.messages.toList(); + _lastMiddleMessage = model.middleMessage; + check(model).contents.length.equals(model.messages.length); for (int i = 0; i < model.contents.length; i++) { final poll = model.messages[i].poll; @@ -2013,6 +2241,17 @@ void checkInvariants(MessageListView model) { } } +void _checkNoIntersection(List xs, List ys, {String? because}) { + // Both lists are sorted by ID. As an optimization, bet on all or nearly all + // of the first list having smaller IDs than all or nearly all of the other. + if (xs.isEmpty || ys.isEmpty) return; + if (xs.last.id < ys.first.id) return; + final yCandidates = Set.of(ys.takeWhile((m) => m.id <= xs.last.id)); + final intersection = xs.reversed.takeWhile((m) => ys.first.id <= m.id) + .where(yCandidates.contains); + check(intersection, because: because).isEmpty(); +} + extension MessageListRecipientHeaderItemChecks on Subject { Subject get message => has((x) => x.message, 'message'); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 69a7204827..df05b4f0cc 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -462,23 +462,61 @@ void main() { // [MessageListScrollView], in scrolling_test.dart . testWidgets('sticks to end upon new message', (tester) async { - await setupMessageListPage(tester, - messages: List.generate(10, (_) => eg.streamMessage(content: '

a

'))); + await setupMessageListPage(tester, messages: List.generate(10, + (i) => eg.streamMessage(content: '

message $i

'))); final controller = findMessageListScrollController(tester)!; + final findMiddleMessage = find.text('message 5'); - // Starts at end, and with room to scroll up. - check(controller.position) - ..extentAfter.equals(0) - ..extentBefore.isGreaterThan(0); - final oldPosition = controller.position.pixels; + // Started out scrolled to the bottom. + check(controller.position).extentAfter.equals(0); + final scrollPixels = controller.position.pixels; + + // Note the position of some mid-screen message. + final messageRect = tester.getRect(findMiddleMessage); + check(messageRect)..top.isGreaterThan(0)..bottom.isLessThan(600); - // On new message, position remains at end… + // When a new message arrives, the existing message moves up… await store.addMessage(eg.streamMessage(content: '

a

b

')); await tester.pump(); + check(tester.getRect(findMiddleMessage)) + ..top.isLessThan(messageRect.top) + ..height.isCloseTo(messageRect.height, Tolerance().distance); + // … because the position remains at the end… check(controller.position) ..extentAfter.equals(0) // … even though that means a bigger number now. - ..pixels.isGreaterThan(oldPosition); + ..pixels.isGreaterThan(scrollPixels); + }); + + testWidgets('preserves visible messages upon new message, when not at end', (tester) async { + await setupMessageListPage(tester, messages: List.generate(10, + (i) => eg.streamMessage(content: '

message $i

'))); + final controller = findMessageListScrollController(tester)!; + final findMiddleMessage = find.text('message 5'); + + // Started at bottom. Scroll up a bit. + check(controller.position).extentAfter.equals(0); + controller.position.jumpTo(controller.position.pixels - 100); + await tester.pump(); + check(controller.position).extentAfter.equals(100); + final scrollPixels = controller.position.pixels; + + // Note the position of some mid-screen message. + final messageRect = tester.getRect(findMiddleMessage); + check(messageRect)..top.isGreaterThan(0)..bottom.isLessThan(600); + + // When a new message arrives, the existing message doesn't shift… + await store.addMessage(eg.streamMessage(content: '

a

b

')); + await tester.pump(); + check(tester.getRect(findMiddleMessage)).equals(messageRect); + // … because the scroll position value remained the same… + check(controller.position) + ..pixels.equals(scrollPixels) + // … even though there's now more content off screen below. + // (This last check relies on the fact that the old extentAfter is small, + // less than cacheExtent, so that the new content is only barely offscreen, + // it gets built, and the new extentAfter reflects it.) + ..extentAfter.isGreaterThan(100); }); }); @@ -1594,15 +1632,8 @@ void main() { // as the number of items changes in MessageList. See // `findChildIndexCallback` passed into [SliverStickyHeaderList] // at [_MessageListState._buildListView]. - - // TODO(#82): Cut paddingMessage. It's there to paper over a glitch: - // the _UnreadMarker animation *does* get interrupted in the case where - // the message gets pushed from one sliver to the other. See: - // https://github.com/zulip/zulip-flutter/pull/1436#issuecomment-2756738779 - // That case will no longer exist when #82 is complete. final message = eg.streamMessage(flags: []); - final paddingMessage = eg.streamMessage(); - await setupMessageListPage(tester, messages: [message, paddingMessage]); + await setupMessageListPage(tester, messages: [message]); check(getAnimation(tester, message.id)) ..value.equals(1.0) ..status.equals(AnimationStatus.dismissed); @@ -1626,11 +1657,10 @@ void main() { ..status.equals(AnimationStatus.forward); // introduce new message - check(find.byType(MessageItem)).findsExactly(2); final newMessage = eg.streamMessage(flags:[MessageFlag.read]); await store.addMessage(newMessage); await tester.pump(); // process handleEvent - check(find.byType(MessageItem)).findsExactly(3); + check(find.byType(MessageItem)).findsExactly(2); check(getAnimation(tester, message.id)) ..value.isGreaterThan(0.0) ..value.isLessThan(1.0) From 82515c38d6f896161b434c7c2949d36212bb3aa4 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 20 May 2025 17:43:28 -0700 Subject: [PATCH 056/290] api: Like zulip-mobile, don't allow connecting to Zulip Server <7.0 This goes rather farther than #1456, which is for bumping this number just to 5.0. This is OK; we recently bumped zulip-mobile to use 7.0, in the v27.235 release. zulip-mobile shows a "nag banner" to nudge server admins to upgrade. As of v27.235 (PR zulip/zulip-mobile#5922), that banner appears on 7.x servers, the latest 7.x having recently aged out of our 18-month support window. So we should feel comfortable nudging this number in zulip-flutter to 8.0 once v27.235 has been out for a little while. Fixes: #1456 --- README.md | 2 +- lib/api/core.dart | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee6477dd67..a8d734d45c 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ good time to [report them as issues][dart-test-tracker]. #### Server compatibility -We support Zulip Server 4.0 and later. +We support Zulip Server 7.0 and later. For API features added in newer versions, use `TODO(server-N)` comments (like those you see in the existing code.) diff --git a/lib/api/core.dart b/lib/api/core.dart index fb8d564ae6..39101f1420 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -14,13 +14,14 @@ import 'exception.dart'; /// /// When updating this, also update [kMinSupportedZulipFeatureLevel] /// and the README. -const kMinSupportedZulipVersion = '4.0'; +// TODO(#268) address all TODO(server-5), TODO(server-6), and TODO(server-7) +const kMinSupportedZulipVersion = '7.0'; /// The Zulip feature level reserved for the [kMinSupportedZulipVersion] release. /// /// For this value, see the API changelog: /// https://zulip.com/api/changelog -const kMinSupportedZulipFeatureLevel = 65; +const kMinSupportedZulipFeatureLevel = 185; /// The doc stating our oldest supported server version. // TODO: Instead, link to new Help Center doc once we have it: From b12e97b88e907a2e6b7db606df883491e13a131a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 20 May 2025 22:11:02 -0700 Subject: [PATCH 057/290] version: Sync version and changelog from v0.0.29 release Ideally would have done this a bit earlier, right after the branch point 77ab9300a; oh well. --- docs/changelog.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 28807677f0..d2d50c022b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,52 @@ ## Unreleased +## 0.0.29 (2025-05-19) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This is a feature-packed release, as this new app gets near ready to +replace the legacy Zulip mobile app a few weeks from now. +Please try out the new features, and as always report anything broken. + +* Initial support for TeX math! Try enabling the + experimental flag, in settings. (#46) +* Edit a message. (#126) +* Initial support to open at first unread message; + try enabling in settings. (#80) +* List of topics in channel. (#1158) +* (iOS) Go to conversation on opening notification. (#1147) + + +### Highlights for developers + +* Further user highlights that didn't fit in 500 characters: + * #1441 simplified local echo, enabling recovery from failed send + * #82 on following a message link, go to specific message + in middle of history + * #930 no more images moving around when you navigate from + one message list to another + * #1250 general chat + * #1470 when you re-open the app after a while and start typing + a message, your draft is preserved across the app's reloading + its data from the server + +* Resolved in main: #1470, #407, #1485, #930, #44, #1250, #126 + +* Resolved in the experimental branch: + * #82, and #80 behind a flag, via PR #1517 + * #1441 via PR #1453 + * #1158 via PR #1500 + * #1495 via PR #1506 + * #127 via PR #1322 + * more toward #46 via PR #1452 + * #1147 via PR #1379 + + ## 0.0.28 (2025-04-21) ### Highlights for users diff --git a/pubspec.yaml b/pubspec.yaml index d9fa354b16..1df9ed3390 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.28+28 +version: 0.0.29+29 environment: # We use a recent version of Flutter from its main channel, and From 2fae1deb1c8c97a7a250fc538f84b0771c0e6294 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 14:04:41 -0700 Subject: [PATCH 058/290] emoji [nfc]: Move list of popular emoji codes to its own static field For #1495, we'll want to keep the hard-coded list of emoji codes but get the names from ServerEmojiData. This refactor prepares for that by separating where we source the codes and the names. --- lib/model/emoji.dart | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 670c574c12..1d5e244df6 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -221,33 +221,45 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { static final _popularCandidates = _generatePopularCandidates(); static List _generatePopularCandidates() { - EmojiCandidate candidate(String emojiCode, String emojiUnicode, - List names) { + EmojiCandidate candidate(String emojiCode, List names) { final emojiName = names.removeAt(0); - assert(emojiUnicode == tryParseEmojiCodeToUnicode(emojiCode)); + final emojiUnicode = tryParseEmojiCodeToUnicode(emojiCode)!; return EmojiCandidate(emojiType: ReactionType.unicodeEmoji, emojiCode: emojiCode, emojiName: emojiName, aliases: names, emojiDisplay: UnicodeEmojiDisplay( emojiName: emojiName, emojiUnicode: emojiUnicode)); } + final list = _popularEmojiCodesList; return [ - // This list should match web: - // https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L22-L29 - candidate('1f44d', '👍', ['+1', 'thumbs_up', 'like']), - candidate('1f389', '🎉', ['tada']), - candidate('1f642', '🙂', ['smile']), - candidate( '2764', '❤', ['heart', 'love', 'love_you']), - candidate('1f6e0', '🛠', ['working_on_it', 'hammer_and_wrench', 'tools']), - candidate('1f419', '🐙', ['octopus']), + candidate(list[0], ['+1', 'thumbs_up', 'like']), + candidate(list[1], ['tada']), + candidate(list[2], ['smile']), + candidate(list[3], ['heart', 'love', 'love_you']), + candidate(list[4], ['working_on_it', 'hammer_and_wrench', 'tools']), + candidate(list[5], ['octopus']), ]; } - static final _popularEmojiCodes = (() { - assert(_popularCandidates.every((c) => - c.emojiType == ReactionType.unicodeEmoji)); - return Set.of(_popularCandidates.map((c) => c.emojiCode)); + /// Codes for the popular emoji, in order; all are Unicode emoji. + // This list should match web: + // https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L22-L29 + static final List _popularEmojiCodesList = (() { + String check(String emojiCode, String emojiUnicode) { + assert(emojiUnicode == tryParseEmojiCodeToUnicode(emojiCode)); + return emojiCode; + } + return [ + check('1f44d', '👍'), + check('1f389', '🎉'), + check('1f642', '🙂'), + check('2764', '❤'), + check('1f6e0', '🛠'), + check('1f419', '🐙'), + ]; })(); + static final Set _popularEmojiCodes = Set.of(_popularEmojiCodesList); + static bool _isPopularEmoji(EmojiCandidate candidate) { return candidate.emojiType == ReactionType.unicodeEmoji && _popularEmojiCodes.contains(candidate.emojiCode); From fc5bd89c99f8608c382bba44b603789a14a4a92e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 14:33:41 -0700 Subject: [PATCH 059/290] emoji [nfc]: Read _popularCandidates only through public getter Later in this series, we'll make _popularCandidates nullable, as a caching implementation detail behind popularEmojiCandidates. (Both will become non-static.) --- lib/model/emoji.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 1d5e244df6..fed5bd5b21 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -319,7 +319,7 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { // Include the "popular" emoji, in their canonical order // relative to each other. - results.addAll(_popularCandidates); + results.addAll(EmojiStore.popularEmojiCandidates); final namesOverridden = { for (final emoji in activeRealmEmoji) emoji.name, From 23902ad5257a3ccdc8e4543fc9c28f3cb8f1c20e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 14:38:29 -0700 Subject: [PATCH 060/290] emoji [nfc]: Make popularEmojiCandidates non-static --- lib/model/emoji.dart | 8 ++------ lib/widgets/action_sheet.dart | 7 ++++--- test/model/emoji_test.dart | 2 +- test/widgets/action_sheet_test.dart | 2 +- test/widgets/emoji_reaction_test.dart | 2 +- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index fed5bd5b21..ca53af10b6 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -115,11 +115,7 @@ mixin EmojiStore { /// /// See description in the web code: /// https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L3-L21 - // Someday this list may start varying rather than being hard-coded, - // and then this will become a non-static member on EmojiStore. - // For now, though, the fact it's constant is convenient when writing - // tests of the logic that uses this data; so we guarantee it in the API. - static Iterable get popularEmojiCandidates { + Iterable get popularEmojiCandidates { return EmojiStoreImpl._popularCandidates; } @@ -319,7 +315,7 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { // Include the "popular" emoji, in their canonical order // relative to each other. - results.addAll(EmojiStore.popularEmojiCandidates); + results.addAll(popularEmojiCandidates); final namesOverridden = { for (final emoji in activeRealmEmoji) emoji.name, diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 43db39dc18..0515c2b901 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -698,11 +698,12 @@ class ReactionButtons extends StatelessWidget { @override Widget build(BuildContext context) { - assert(EmojiStore.popularEmojiCandidates.every( + final store = PerAccountStoreWidget.of(pageContext); + final popularEmojiCandidates = store.popularEmojiCandidates; + assert(popularEmojiCandidates.every( (emoji) => emoji.emojiType == ReactionType.unicodeEmoji)); final zulipLocalizations = ZulipLocalizations.of(context); - final store = PerAccountStoreWidget.of(pageContext); final designVariables = DesignVariables.of(context); bool hasSelfVote(EmojiCandidate emoji) { @@ -718,7 +719,7 @@ class ReactionButtons extends StatelessWidget { color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)), child: Row(children: [ Flexible(child: Row(spacing: 1, children: List.unmodifiable( - EmojiStore.popularEmojiCandidates.mapIndexed((index, emoji) => + popularEmojiCandidates.mapIndexed((index, emoji) => _buildButton( context: context, emoji: emoji, diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index 7916af0849..057ddd5ff2 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -78,7 +78,7 @@ void main() { }); }); - final popularCandidates = EmojiStore.popularEmojiCandidates; + final popularCandidates = eg.store().popularEmojiCandidates; Condition isUnicodeCandidate(String? emojiCode, List? names) { return (it_) { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index ef7b8e64b3..0e5b39cf42 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -829,7 +829,7 @@ void main() { group('message action sheet', () { group('ReactionButtons', () { - final popularCandidates = EmojiStore.popularEmojiCandidates; + final popularCandidates = eg.store().popularEmojiCandidates; for (final emoji in popularCandidates) { final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index d8faed191f..38c43c06bf 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -299,7 +299,7 @@ void main() { // - Non-animated image emoji is selected when intended group('EmojiPicker', () { - final popularCandidates = EmojiStore.popularEmojiCandidates; + final popularCandidates = eg.store().popularEmojiCandidates; Future setupEmojiPicker(WidgetTester tester, { required StreamMessage message, From 05221588ad71de0c49957578f737c283e653b84d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 14:41:14 -0700 Subject: [PATCH 061/290] emoji [nfc]: Make popularEmojiCandidates a method, not getter For parallelism with allEmojiCandidates, which is similar. --- lib/model/emoji.dart | 4 ++-- lib/widgets/action_sheet.dart | 2 +- test/model/emoji_test.dart | 2 +- test/widgets/action_sheet_test.dart | 2 +- test/widgets/emoji_reaction_test.dart | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index ca53af10b6..4eca8fdbc1 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -115,7 +115,7 @@ mixin EmojiStore { /// /// See description in the web code: /// https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L3-L21 - Iterable get popularEmojiCandidates { + Iterable popularEmojiCandidates() { return EmojiStoreImpl._popularCandidates; } @@ -315,7 +315,7 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { // Include the "popular" emoji, in their canonical order // relative to each other. - results.addAll(popularEmojiCandidates); + results.addAll(popularEmojiCandidates()); final namesOverridden = { for (final emoji in activeRealmEmoji) emoji.name, diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 0515c2b901..0600f5e2e3 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -699,7 +699,7 @@ class ReactionButtons extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(pageContext); - final popularEmojiCandidates = store.popularEmojiCandidates; + final popularEmojiCandidates = store.popularEmojiCandidates(); assert(popularEmojiCandidates.every( (emoji) => emoji.emojiType == ReactionType.unicodeEmoji)); diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index 057ddd5ff2..2cf3bf2f61 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -78,7 +78,7 @@ void main() { }); }); - final popularCandidates = eg.store().popularEmojiCandidates; + final popularCandidates = eg.store().popularEmojiCandidates(); Condition isUnicodeCandidate(String? emojiCode, List? names) { return (it_) { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 0e5b39cf42..fa53077cf6 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -829,7 +829,7 @@ void main() { group('message action sheet', () { group('ReactionButtons', () { - final popularCandidates = eg.store().popularEmojiCandidates; + final popularCandidates = eg.store().popularEmojiCandidates(); for (final emoji in popularCandidates) { final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 38c43c06bf..343dd406ab 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -299,7 +299,7 @@ void main() { // - Non-animated image emoji is selected when intended group('EmojiPicker', () { - final popularCandidates = eg.store().popularEmojiCandidates; + final popularCandidates = eg.store().popularEmojiCandidates(); Future setupEmojiPicker(WidgetTester tester, { required StreamMessage message, From 8c2dfe7fb18b150a59280d87e284edd5f6b132ec Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 14:45:59 -0700 Subject: [PATCH 062/290] emoji [nfc]: Move popularEmojiCandidates implementation down out of mixin --- lib/model/emoji.dart | 9 ++++++--- lib/model/store.dart | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 4eca8fdbc1..554cd4f468 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -115,9 +115,7 @@ mixin EmojiStore { /// /// See description in the web code: /// https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L3-L21 - Iterable popularEmojiCandidates() { - return EmojiStoreImpl._popularCandidates; - } + Iterable popularEmojiCandidates(); Iterable allEmojiCandidates(); @@ -216,6 +214,11 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { static final _popularCandidates = _generatePopularCandidates(); + @override + Iterable popularEmojiCandidates() { + return _popularCandidates; + } + static List _generatePopularCandidates() { EmojiCandidate candidate(String emojiCode, List names) { final emojiName = names.removeAt(0); diff --git a/lib/model/store.dart b/lib/model/store.dart index 5f5b19128b..297e4300bc 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -611,6 +611,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor notifyListeners(); } + @override + Iterable popularEmojiCandidates() => _emoji.popularEmojiCandidates(); + @override Iterable allEmojiCandidates() => _emoji.allEmojiCandidates(); From eee7bcd8f9aef0d4c6021fb7db24e5381011401e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 16:35:41 -0700 Subject: [PATCH 063/290] emoji test: Change one autocomplete test to use a non-popular emoji We're about to change `prepare`'s unicodeEmoji param so that it expects only non-popular emoji. --- test/model/emoji_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index 2cf3bf2f61..a4378acb1a 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -343,7 +343,7 @@ void main() { test('results update after query change', () async { final store = prepare( - realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f642': ['smile']}); + realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f516': ['bookmark']}); final view = EmojiAutocompleteView.init(store: store, query: EmojiAutocompleteQuery('hap')); bool done = false; @@ -354,11 +354,11 @@ void main() { isRealmResult(emojiName: 'happy')); done = false; - view.query = EmojiAutocompleteQuery('sm'); + view.query = EmojiAutocompleteQuery('bo'); await Future(() {}); check(done).isTrue(); check(view.results).single.which( - isUnicodeResult(names: ['smile'])); + isUnicodeResult(names: ['bookmark'])); }); Future> resultsOf( From f1b65bbd1e3ce4fe11785175961ef1ced1668d4c Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 16:31:31 -0700 Subject: [PATCH 064/290] test: Add eg.serverEmojiDataPopular for tests that would break without it This brings the tests closer to being representative, because this data (and more) is part of the app's setup for an account on startup, via fetchServerEmojiData. For #1495, the app will start depending on this data for the names of popular emoji. So, prepare for that by filling in the data in tests that would break without it. --- test/example_data.dart | 22 ++++++++++++++++ test/model/emoji_test.dart | 37 ++++++++++++++++++--------- test/widgets/action_sheet_test.dart | 5 +++- test/widgets/emoji_reaction_test.dart | 11 +++++--- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index e0f44f9ddc..dd245014a4 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -129,6 +129,28 @@ GetServerSettingsResult serverSettings({ ); } +ServerEmojiData serverEmojiDataPopular = ServerEmojiData(codeToNames: { + '1f44d': ['+1', 'thumbs_up', 'like'], + '1f389': ['tada'], + '1f642': ['smile'], + '2764': ['heart', 'love', 'love_you'], + '1f6e0': ['working_on_it', 'hammer_and_wrench', 'tools'], + '1f419': ['octopus'], +}); + +ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { + final a = serverEmojiDataPopular; + final b = data; + final result = ServerEmojiData( + codeToNames: {...a.codeToNames, ...b.codeToNames}, + ); + assert( + result.codeToNames.length == a.codeToNames.length + b.codeToNames.length, + 'eg.serverEmojiDataPopularPlus called with data that collides with eg.serverEmojiDataPopular', + ); + return result; +} + RealmEmojiItem realmEmojiItem({ required String emojiCode, required String emojiName, diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index a4378acb1a..caee41bcca 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -78,7 +78,10 @@ void main() { }); }); - final popularCandidates = eg.store().popularEmojiCandidates(); + final popularCandidates = ( + eg.store()..setServerEmojiData(eg.serverEmojiDataPopular) + ).popularEmojiCandidates(); + assert(popularCandidates.length == 6); Condition isUnicodeCandidate(String? emojiCode, List? names) { return (it_) { @@ -118,18 +121,25 @@ void main() { PerAccountStore prepare({ Map realmEmoji = const {}, + bool addServerDataForPopular = true, Map>? unicodeEmoji, }) { final store = eg.store( initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); - if (unicodeEmoji != null) { - store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji)); + + if (addServerDataForPopular || unicodeEmoji != null) { + final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); + final emojiData = addServerDataForPopular + ? eg.serverEmojiDataPopularPlus(extraEmojiData) + : extraEmojiData; + store.setServerEmojiData(emojiData); } return store; } test('popular emoji appear even when no server emoji data', () { - final store = prepare(unicodeEmoji: null); + final store = prepare(unicodeEmoji: null, addServerDataForPopular: false); + check(store.debugServerEmojiData).isNull(); check(store.allEmojiCandidates()).deepEquals([ ...arePopularCandidates, isZulipCandidate(), @@ -139,7 +149,8 @@ void main() { test('popular emoji appear in their canonical order', () { // In the server's emoji data, have the popular emoji in a permuted order, // and interspersed with other emoji. - final store = prepare(unicodeEmoji: { + assert(popularCandidates.length == 6); + final store = prepare(addServerDataForPopular: false, unicodeEmoji: { '1f603': ['smiley'], for (final candidate in popularCandidates.skip(3)) candidate.emojiCode: [candidate.emojiName, ...candidate.aliases], @@ -246,15 +257,17 @@ void main() { }); test('updates on setServerEmojiData', () { - final store = prepare(); + final store = prepare(unicodeEmoji: null, addServerDataForPopular: false); + check(store.debugServerEmojiData).isNull(); check(store.allEmojiCandidates()).deepEquals([ ...arePopularCandidates, isZulipCandidate(), ]); - store.setServerEmojiData(ServerEmojiData(codeToNames: { - '1f516': ['bookmark'], - })); + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f516': ['bookmark'], + }))); check(store.allEmojiCandidates()).deepEquals([ ...arePopularCandidates, isUnicodeCandidate('1f516', ['bookmark']), @@ -318,9 +331,9 @@ void main() { for (final MapEntry(:key, :value) in realmEmoji.entries) key: eg.realmEmojiItem(emojiCode: key, emojiName: value), })); - if (unicodeEmoji != null) { - store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji)); - } + final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); + ServerEmojiData emojiData = eg.serverEmojiDataPopularPlus(extraEmojiData); + store.setServerEmojiData(emojiData); return store; } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index fa53077cf6..a39f2efdf5 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -78,6 +78,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { await store.addSubscription(eg.subscription(stream)); } connection = store.connection as FakeApiConnection; + store.setServerEmojiData(eg.serverEmojiDataPopular); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); @@ -829,7 +830,9 @@ void main() { group('message action sheet', () { group('ReactionButtons', () { - final popularCandidates = eg.store().popularEmojiCandidates(); + final popularCandidates = + (eg.store()..setServerEmojiData(eg.serverEmojiDataPopular)) + .popularEmojiCandidates(); for (final emoji in popularCandidates) { final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 343dd406ab..b824c476c4 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -299,7 +299,9 @@ void main() { // - Non-animated image emoji is selected when intended group('EmojiPicker', () { - final popularCandidates = eg.store().popularEmojiCandidates(); + final popularCandidates = + (eg.store()..setServerEmojiData(eg.serverEmojiDataPopular)) + .popularEmojiCandidates(); Future setupEmojiPicker(WidgetTester tester, { required StreamMessage message, @@ -337,9 +339,10 @@ void main() { // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - store.setServerEmojiData(ServerEmojiData(codeToNames: { - '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) - })); + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) + }))); await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'), })); From 0c44bcb0f901870e3e843ce08650fed7fd5b37cd Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 16:39:32 -0700 Subject: [PATCH 065/290] emoji [nfc]: Make a helper's helper not mutate lists of emoji names We're about to change _generatePopularCandidates so it looks up the emoji names in the ServerEmojiData, and we don't want to mutate ServerEmojiData. This isn't a hot codepath (it's called at most once per server-emoji-data fetch), so creating a new List isn't a problem. --- lib/model/emoji.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 554cd4f468..cc50ac0676 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -221,10 +221,10 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { static List _generatePopularCandidates() { EmojiCandidate candidate(String emojiCode, List names) { - final emojiName = names.removeAt(0); + final [emojiName, ...aliases] = names; final emojiUnicode = tryParseEmojiCodeToUnicode(emojiCode)!; return EmojiCandidate(emojiType: ReactionType.unicodeEmoji, - emojiCode: emojiCode, emojiName: emojiName, aliases: names, + emojiCode: emojiCode, emojiName: emojiName, aliases: aliases, emojiDisplay: UnicodeEmojiDisplay( emojiName: emojiName, emojiUnicode: emojiUnicode)); } From bf60af5197f892c461570159986d168f799fa775 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 16:56:42 -0700 Subject: [PATCH 066/290] emoji_reaction test: Do a store.setServerEmojiData earlier The message action sheet is about to condition the appearance of the reactions row on whether we have ServerEmojiData for any of the popular emoji. This test-setup code taps "more" on that row to launch the emoji picker, so the data must be present by the time the action sheet opens. --- test/widgets/emoji_reaction_test.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index b824c476c4..8812f059b9 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -332,6 +332,11 @@ void main() { await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage(initNarrow: narrow))); + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) + }))); + // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); // request the message action sheet @@ -339,10 +344,6 @@ void main() { // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - store.setServerEmojiData(eg.serverEmojiDataPopularPlus( - ServerEmojiData(codeToNames: { - '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) - }))); await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'), })); From 140c4cb24c0ce1e204b3b7338b95f0f75a959223 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 15 May 2025 16:43:09 -0700 Subject: [PATCH 067/290] emoji test [nfc]: Make a nested `prepare` more like another (1/2) We'd like to deduplicate and share this setup code. --- test/model/emoji_test.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index caee41bcca..e4eec6feb8 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -324,6 +324,7 @@ void main() { PerAccountStore prepare({ Map realmEmoji = const {}, + bool addServerDataForPopular = true, Map>? unicodeEmoji, }) { final store = eg.store( @@ -331,9 +332,13 @@ void main() { for (final MapEntry(:key, :value) in realmEmoji.entries) key: eg.realmEmojiItem(emojiCode: key, emojiName: value), })); - final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); - ServerEmojiData emojiData = eg.serverEmojiDataPopularPlus(extraEmojiData); - store.setServerEmojiData(emojiData); + if (addServerDataForPopular || unicodeEmoji != null) { + final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); + final emojiData = addServerDataForPopular + ? eg.serverEmojiDataPopularPlus(extraEmojiData) + : extraEmojiData; + store.setServerEmojiData(emojiData); + } return store; } From 39d57e245f2fe4f6f7345c7c9536ce5b53f6a201 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 15 May 2025 16:46:06 -0700 Subject: [PATCH 068/290] emoji test [nfc]: Make a nested `prepare` more like another (2/2) This shorthand wasn't saving *that* much boilerplate; seems okay to remove it. The two `prepare` functions are now identical; we'll deduplicate them next. --- test/model/emoji_test.dart | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index e4eec6feb8..cc2ca3408c 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -323,15 +323,13 @@ void main() { (c) => isUnicodeResult(emojiCode: c.emojiCode)).toList(); PerAccountStore prepare({ - Map realmEmoji = const {}, + Map realmEmoji = const {}, bool addServerDataForPopular = true, Map>? unicodeEmoji, }) { final store = eg.store( - initialSnapshot: eg.initialSnapshot(realmEmoji: { - for (final MapEntry(:key, :value) in realmEmoji.entries) - key: eg.realmEmojiItem(emojiCode: key, emojiName: value), - })); + initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); + if (addServerDataForPopular || unicodeEmoji != null) { final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); final emojiData = addServerDataForPopular @@ -344,7 +342,9 @@ void main() { test('results can include all three emoji types', () async { final store = prepare( - realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f516': ['bookmark']}); + realmEmoji: {'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy')}, + unicodeEmoji: {'1f516': ['bookmark']}, + ); final view = EmojiAutocompleteView.init(store: store, query: EmojiAutocompleteQuery('')); bool done = false; @@ -361,7 +361,8 @@ void main() { test('results update after query change', () async { final store = prepare( - realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f516': ['bookmark']}); + realmEmoji: {'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy')}, + unicodeEmoji: {'1f516': ['bookmark']}); final view = EmojiAutocompleteView.init(store: store, query: EmojiAutocompleteQuery('hap')); bool done = false; @@ -381,7 +382,7 @@ void main() { Future> resultsOf( String query, { - Map realmEmoji = const {}, + Map realmEmoji = const {}, Map>? unicodeEmoji, }) async { final store = prepare(realmEmoji: realmEmoji, unicodeEmoji: unicodeEmoji); From e55cebd48e2cd9b8f8951c1200e3f29f8db2e2c2 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 15 May 2025 16:47:04 -0700 Subject: [PATCH 069/290] emoji test [nfc]: Pull out `prepare` function for reuse --- test/model/emoji_test.dart | 54 +++++++++++++------------------------- 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index cc2ca3408c..d6b541b981 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -10,6 +10,24 @@ import 'package:zulip/model/store.dart'; import '../example_data.dart' as eg; void main() { + PerAccountStore prepare({ + Map realmEmoji = const {}, + bool addServerDataForPopular = true, + Map>? unicodeEmoji, + }) { + final store = eg.store( + initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); + + if (addServerDataForPopular || unicodeEmoji != null) { + final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); + final emojiData = addServerDataForPopular + ? eg.serverEmojiDataPopularPlus(extraEmojiData) + : extraEmojiData; + store.setServerEmojiData(emojiData); + } + return store; + } + group('emojiDisplayFor', () { test('Unicode emoji', () { check(eg.store().emojiDisplayFor(emojiType: ReactionType.unicodeEmoji, @@ -119,24 +137,6 @@ void main() { group('allEmojiCandidates', () { // TODO test emojiDisplay of candidates matches emojiDisplayFor - PerAccountStore prepare({ - Map realmEmoji = const {}, - bool addServerDataForPopular = true, - Map>? unicodeEmoji, - }) { - final store = eg.store( - initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); - - if (addServerDataForPopular || unicodeEmoji != null) { - final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); - final emojiData = addServerDataForPopular - ? eg.serverEmojiDataPopularPlus(extraEmojiData) - : extraEmojiData; - store.setServerEmojiData(emojiData); - } - return store; - } - test('popular emoji appear even when no server emoji data', () { final store = prepare(unicodeEmoji: null, addServerDataForPopular: false); check(store.debugServerEmojiData).isNull(); @@ -322,24 +322,6 @@ void main() { List> arePopularResults = popularCandidates.map( (c) => isUnicodeResult(emojiCode: c.emojiCode)).toList(); - PerAccountStore prepare({ - Map realmEmoji = const {}, - bool addServerDataForPopular = true, - Map>? unicodeEmoji, - }) { - final store = eg.store( - initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); - - if (addServerDataForPopular || unicodeEmoji != null) { - final extraEmojiData = ServerEmojiData(codeToNames: unicodeEmoji ?? {}); - final emojiData = addServerDataForPopular - ? eg.serverEmojiDataPopularPlus(extraEmojiData) - : extraEmojiData; - store.setServerEmojiData(emojiData); - } - return store; - } - test('results can include all three emoji types', () async { final store = prepare( realmEmoji: {'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy')}, From 34a562a09d8d441f5b6c662542a8859f69950651 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 14:50:38 -0700 Subject: [PATCH 070/290] emoji: Generate popular candidates using names from server data The server change in zulip/zulip#34177, renaming `:smile:` to `:slight_smile:`, broke the corresponding reaction button in the message action sheet. We've been sending the add/remove-reaction request with the old name 'smile', which modern servers reject. To fix, take the popular emoji names from ServerEmojiData, so that we'll use the correct name on servers before and after the change. API-design discussion: https://chat.zulip.org/#narrow/channel/378-api-design/topic/.23F1495.20smile.2Fslight_smile.20change.20broke.20reaction.20button/near/2170354 Fixes: #1495 --- lib/model/emoji.dart | 27 ++--- lib/widgets/action_sheet.dart | 11 ++- test/example_data.dart | 14 +++ test/model/emoji_test.dart | 48 +++++++-- test/widgets/action_sheet_test.dart | 146 +++++++++++++++++----------- 5 files changed, 163 insertions(+), 83 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index cc50ac0676..0923fdab79 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -212,14 +212,14 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { /// retrieving the data. Map>? _serverEmojiData; - static final _popularCandidates = _generatePopularCandidates(); + List? _popularCandidates; @override Iterable popularEmojiCandidates() { - return _popularCandidates; + return _popularCandidates ??= _generatePopularCandidates(); } - static List _generatePopularCandidates() { + List _generatePopularCandidates() { EmojiCandidate candidate(String emojiCode, List names) { final [emojiName, ...aliases] = names; final emojiUnicode = tryParseEmojiCodeToUnicode(emojiCode)!; @@ -228,20 +228,20 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { emojiDisplay: UnicodeEmojiDisplay( emojiName: emojiName, emojiUnicode: emojiUnicode)); } - final list = _popularEmojiCodesList; - return [ - candidate(list[0], ['+1', 'thumbs_up', 'like']), - candidate(list[1], ['tada']), - candidate(list[2], ['smile']), - candidate(list[3], ['heart', 'love', 'love_you']), - candidate(list[4], ['working_on_it', 'hammer_and_wrench', 'tools']), - candidate(list[5], ['octopus']), - ]; + if (_serverEmojiData == null) return []; + + final result = []; + for (final emojiCode in _popularEmojiCodesList) { + final names = _serverEmojiData![emojiCode]; + if (names == null) continue; // TODO(log) + result.add(candidate(emojiCode, names)); + } + return result; } /// Codes for the popular emoji, in order; all are Unicode emoji. // This list should match web: - // https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L22-L29 + // https://github.com/zulip/zulip/blob/9feba0f16/web/shared/src/typeahead.ts#L22-L29 static final List _popularEmojiCodesList = (() { String check(String emojiCode, String emojiUnicode) { assert(emojiUnicode == tryParseEmojiCodeToUnicode(emojiCode)); @@ -377,6 +377,7 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { @override void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; + _popularCandidates = null; _allEmojiCandidates = null; } diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 0600f5e2e3..04e535e65a 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -555,6 +555,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); + final popularEmojiLoaded = store.popularEmojiCandidates().isNotEmpty; + // 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). @@ -568,7 +570,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; final optionButtons = [ - ReactionButtons(message: message, pageContext: pageContext), + if (popularEmojiLoaded) + ReactionButtons(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), @@ -702,6 +705,12 @@ class ReactionButtons extends StatelessWidget { final popularEmojiCandidates = store.popularEmojiCandidates(); assert(popularEmojiCandidates.every( (emoji) => emoji.emojiType == ReactionType.unicodeEmoji)); + // (if this is empty, the widget isn't built in the first place) + assert(popularEmojiCandidates.isNotEmpty); + // UI not designed to handle more than 6 popular emoji. + // (We might have fewer if ServerEmojiData is lacking expected data, + // but that looks fine in manual testing, even when there's just one.) + assert(popularEmojiCandidates.length <= 6); final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); diff --git a/test/example_data.dart b/test/example_data.dart index dd245014a4..45bb751bf1 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -151,6 +151,20 @@ ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { return result; } +/// Like [serverEmojiDataPopular], but with the modern '1f642': ['slight_smile'] +/// instead of '1f642': ['smile']; see zulip/zulip@9feba0f16f. +/// +/// zulip/zulip@9feba0f16f is a Server 11 commit. +// TODO(server-11) can drop legacy data +ServerEmojiData serverEmojiDataPopularModern = ServerEmojiData(codeToNames: { + '1f44d': ['+1', 'thumbs_up', 'like'], + '1f389': ['tada'], + '1f642': ['slight_smile'], + '2764': ['heart', 'love', 'love_you'], + '1f6e0': ['working_on_it', 'hammer_and_wrench', 'tools'], + '1f419': ['octopus'], +}); + RealmEmojiItem realmEmojiItem({ required String emojiCode, required String emojiName, diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index d6b541b981..a5bdb045ba 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -137,15 +137,6 @@ void main() { group('allEmojiCandidates', () { // TODO test emojiDisplay of candidates matches emojiDisplayFor - test('popular emoji appear even when no server emoji data', () { - final store = prepare(unicodeEmoji: null, addServerDataForPopular: false); - check(store.debugServerEmojiData).isNull(); - check(store.allEmojiCandidates()).deepEquals([ - ...arePopularCandidates, - isZulipCandidate(), - ]); - }); - test('popular emoji appear in their canonical order', () { // In the server's emoji data, have the popular emoji in a permuted order, // and interspersed with other emoji. @@ -260,7 +251,6 @@ void main() { final store = prepare(unicodeEmoji: null, addServerDataForPopular: false); check(store.debugServerEmojiData).isNull(); check(store.allEmojiCandidates()).deepEquals([ - ...arePopularCandidates, isZulipCandidate(), ]); @@ -303,6 +293,44 @@ void main() { }); }); + group('popularEmojiCandidates', () { + test('memoizes result, before setServerEmojiData', () { + final store = eg.store(); + check(store.debugServerEmojiData).isNull(); + final candidates = store.popularEmojiCandidates(); + check(store.popularEmojiCandidates()) + ..isEmpty()..identicalTo(candidates); + }); + + test('memoizes result, after setServerEmojiData', () { + final store = prepare(); + check(store.debugServerEmojiData).isNotNull(); + final candidates = store.popularEmojiCandidates(); + check(store.popularEmojiCandidates()) + ..isNotEmpty()..identicalTo(candidates); + }); + + test('updates on first and subsequent setServerEmojiData', () { + final store = eg.store(); + check(store.debugServerEmojiData).isNull(); + + final candidates1 = store.popularEmojiCandidates(); + check(candidates1).isEmpty(); + + store.setServerEmojiData(eg.serverEmojiDataPopular); + final candidates2 = store.popularEmojiCandidates(); + check(candidates2) + ..isNotEmpty() + ..not((it) => it.identicalTo(candidates1)); + + store.setServerEmojiData(eg.serverEmojiDataPopularModern); + final candidates3 = store.popularEmojiCandidates(); + check(candidates3) + ..isNotEmpty() + ..not((it) => it.identicalTo(candidates2)); + }); + }); + group('EmojiAutocompleteView', () { Condition isUnicodeResult({String? emojiCode, List? names}) { return (it) => it.isA().candidate.which( diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index a39f2efdf5..07d1695382 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -55,6 +55,8 @@ Future setupToMessageActionSheet(WidgetTester tester, { required Narrow narrow, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, + bool shouldSetServerEmojiData = true, + bool useLegacyServerEmojiData = false, }) async { addTearDown(testBinding.reset); assert(narrow.containsMessage(message)); @@ -78,7 +80,11 @@ Future setupToMessageActionSheet(WidgetTester tester, { await store.addSubscription(eg.subscription(stream)); } connection = store.connection as FakeApiConnection; - store.setServerEmojiData(eg.serverEmojiDataPopular); + if (shouldSetServerEmojiData) { + store.setServerEmojiData(useLegacyServerEmojiData + ? eg.serverEmojiDataPopular + : eg.serverEmojiDataPopularModern); + } connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); @@ -830,74 +836,96 @@ void main() { group('message action sheet', () { group('ReactionButtons', () { - final popularCandidates = - (eg.store()..setServerEmojiData(eg.serverEmojiDataPopular)) - .popularEmojiCandidates(); - - for (final emoji in popularCandidates) { - final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; + testWidgets('absent if ServerEmojiData not loaded', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + shouldSetServerEmojiData: false); + check(find.byType(ReactionButtons)).findsNothing(); + }); - Future tapButton(WidgetTester tester) async { - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: find.text(emojiDisplay.emojiUnicode))); - } + for (final useLegacy in [false, true]) { + final popularCandidates = + (eg.store()..setServerEmojiData( + useLegacy + ? eg.serverEmojiDataPopular + : eg.serverEmojiDataPopularModern)) + .popularEmojiCandidates(); + for (final emoji in popularCandidates) { + final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; + + Future tapButton(WidgetTester tester) async { + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.text(emojiDisplay.emojiUnicode))); + } - testWidgets('${emoji.emojiName} adding success', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('${emoji.emojiName} adding success; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') - ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); - }); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); + }); - testWidgets('${emoji.emojiName} removing success', (tester) async { - final message = eg.streamMessage( - reactions: [Reaction( - emojiName: emoji.emojiName, - emojiCode: emoji.emojiCode, - reactionType: ReactionType.unicodeEmoji, - userId: eg.selfAccount.userId)] - ); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('${emoji.emojiName} removing success; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage( + reactions: [Reaction( + emojiName: emoji.emojiName, + emojiCode: emoji.emojiCode, + reactionType: ReactionType.unicodeEmoji, + userId: eg.selfAccount.userId)] + ); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); - check(connection.lastRequest).isA() - ..method.equals('DELETE') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') - ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); - }); + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); + }); - testWidgets('${emoji.emojiName} request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('${emoji.emojiName} request has an error; useLegacy: $useLegacy', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + useLegacyServerEmojiData: useLegacy); - connection.prepare( - apiException: eg.apiBadRequest(message: 'Invalid message(s)')); - await tapButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); + await tapButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Adding reaction failed', - expectedMessage: 'Invalid message(s)'))); - }); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Adding reaction failed', + expectedMessage: 'Invalid message(s)'))); + }); + } } }); From df51b3f88ef143a99e7ea77fd2ac92ae06aae6af Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 7 May 2025 17:07:21 -0700 Subject: [PATCH 071/290] test: Change '1f642': 'smile' emoji to 'slight_smile' where it appears --- test/api/route/realm_test.dart | 2 +- test/example_data.dart | 12 ++++++------ test/model/emoji_test.dart | 12 +++++++----- test/model/store_test.dart | 2 +- test/widgets/action_sheet_test.dart | 8 ++++---- test/widgets/emoji_reaction_test.dart | 10 +++++----- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/test/api/route/realm_test.dart b/test/api/route/realm_test.dart index c1cc18b98b..5d11a9d51f 100644 --- a/test/api/route/realm_test.dart +++ b/test/api/route/realm_test.dart @@ -22,7 +22,7 @@ void main() { } final fakeResult = ServerEmojiData(codeToNames: { - '1f642': ['smile'], + '1f642': ['slight_smile'], '1f34a': ['orange', 'tangerine', 'mandarin'], }); diff --git a/test/example_data.dart b/test/example_data.dart index 45bb751bf1..c80fcf4528 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -132,7 +132,7 @@ GetServerSettingsResult serverSettings({ ServerEmojiData serverEmojiDataPopular = ServerEmojiData(codeToNames: { '1f44d': ['+1', 'thumbs_up', 'like'], '1f389': ['tada'], - '1f642': ['smile'], + '1f642': ['slight_smile'], '2764': ['heart', 'love', 'love_you'], '1f6e0': ['working_on_it', 'hammer_and_wrench', 'tools'], '1f419': ['octopus'], @@ -151,15 +151,15 @@ ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { return result; } -/// Like [serverEmojiDataPopular], but with the modern '1f642': ['slight_smile'] -/// instead of '1f642': ['smile']; see zulip/zulip@9feba0f16f. +/// Like [serverEmojiDataPopular], but with the legacy '1f642': ['smile'] +/// instead of '1f642': ['slight_smile']; see zulip/zulip@9feba0f16f. /// /// zulip/zulip@9feba0f16f is a Server 11 commit. -// TODO(server-11) can drop legacy data -ServerEmojiData serverEmojiDataPopularModern = ServerEmojiData(codeToNames: { +// TODO(server-11) can drop this +ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: { '1f44d': ['+1', 'thumbs_up', 'like'], '1f389': ['tada'], - '1f642': ['slight_smile'], + '1f642': ['smile'], '2764': ['heart', 'love', 'love_you'], '1f6e0': ['working_on_it', 'hammer_and_wrench', 'tools'], '1f419': ['octopus'], diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index a5bdb045ba..d96ccae5e4 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -31,9 +31,9 @@ void main() { group('emojiDisplayFor', () { test('Unicode emoji', () { check(eg.store().emojiDisplayFor(emojiType: ReactionType.unicodeEmoji, - emojiCode: '1f642', emojiName: 'smile') + emojiCode: '1f642', emojiName: 'slight_smile') ).isA() - ..emojiName.equals('smile') + ..emojiName.equals('slight_smile') ..emojiUnicode.equals('🙂'); }); @@ -317,13 +317,13 @@ void main() { final candidates1 = store.popularEmojiCandidates(); check(candidates1).isEmpty(); - store.setServerEmojiData(eg.serverEmojiDataPopular); + store.setServerEmojiData(eg.serverEmojiDataPopularLegacy); final candidates2 = store.popularEmojiCandidates(); check(candidates2) ..isNotEmpty() ..not((it) => it.identicalTo(candidates1)); - store.setServerEmojiData(eg.serverEmojiDataPopularModern); + store.setServerEmojiData(eg.serverEmojiDataPopular); final candidates3 = store.popularEmojiCandidates(); check(candidates3) ..isNotEmpty() @@ -418,7 +418,7 @@ void main() { check(await resultsOf('')).deepEquals([ isUnicodeResult(names: ['+1', 'thumbs_up', 'like']), isUnicodeResult(names: ['tada']), - isUnicodeResult(names: ['smile']), + isUnicodeResult(names: ['slight_smile']), isUnicodeResult(names: ['heart', 'love', 'love_you']), isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), isUnicodeResult(names: ['octopus']), @@ -431,6 +431,7 @@ void main() { isUnicodeResult(names: ['tada']), isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), // other + isUnicodeResult(names: ['slight_smile']), isUnicodeResult(names: ['heart', 'love', 'love_you']), isUnicodeResult(names: ['octopus']), ]); @@ -441,6 +442,7 @@ void main() { isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']), // other isUnicodeResult(names: ['+1', 'thumbs_up', 'like']), + isUnicodeResult(names: ['slight_smile']), ]); }); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index ef0a7a72be..eba1505747 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -705,7 +705,7 @@ void main() { final emojiDataUrl = Uri.parse('https://cdn.example/emoji.json'); final data = { - '1f642': ['smile'], + '1f642': ['slight_smile'], '1f34a': ['orange', 'tangerine', 'mandarin'], }; diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 07d1695382..6b8011510a 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -82,8 +82,8 @@ Future setupToMessageActionSheet(WidgetTester tester, { connection = store.connection as FakeApiConnection; if (shouldSetServerEmojiData) { store.setServerEmojiData(useLegacyServerEmojiData - ? eg.serverEmojiDataPopular - : eg.serverEmojiDataPopularModern); + ? eg.serverEmojiDataPopularLegacy + : eg.serverEmojiDataPopular); } connection.prepare(json: eg.newestGetMessagesResult( @@ -849,8 +849,8 @@ void main() { final popularCandidates = (eg.store()..setServerEmojiData( useLegacy - ? eg.serverEmojiDataPopular - : eg.serverEmojiDataPopularModern)) + ? eg.serverEmojiDataPopularLegacy + : eg.serverEmojiDataPopular)) .popularEmojiCandidates(); for (final emoji in popularCandidates) { final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 8812f059b9..de3ad7227c 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -161,7 +161,7 @@ void main() { // Base JSON for various unicode emoji reactions. Just missing user_id. final u1 = {'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji'}; final u2 = {'emoji_name': 'family_man_man_girl_boy', 'emoji_code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'reaction_type': 'unicode_emoji'}; - final u3 = {'emoji_name': 'smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}; + final u3 = {'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}; final u4 = {'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}; final u5 = {'emoji_name': 'exploding_head', 'emoji_code': '1f92f', 'reaction_type': 'unicode_emoji'}; @@ -239,7 +239,7 @@ void main() { await setupChipsInBox(tester, reactions: [ Reaction.fromJson({ 'user_id': eg.selfUser.userId, - 'emoji_name': 'smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}), + 'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}), Reaction.fromJson({ 'user_id': eg.otherUser.userId, 'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}), @@ -251,7 +251,7 @@ void main() { return material.color; } - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(EmojiReactionTheme.light.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(EmojiReactionTheme.light.bgUnselected); @@ -261,13 +261,13 @@ void main() { await tester.pump(kThemeAnimationDuration * 0.4); final expectedLerped = EmojiReactionTheme.light.lerp(EmojiReactionTheme.dark, 0.4); - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(expectedLerped.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(expectedLerped.bgUnselected); await tester.pump(kThemeAnimationDuration * 0.6); - check(backgroundColor('smile')).isNotNull() + check(backgroundColor('slight_smile')).isNotNull() .isSameColorAs(EmojiReactionTheme.dark.bgSelected); check(backgroundColor('tada')).isNotNull() .isSameColorAs(EmojiReactionTheme.dark.bgUnselected); From 4effb82a9ebdff8ca18099344d20c3e65051bd7c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 19 May 2025 20:37:12 -0700 Subject: [PATCH 072/290] l10n: Update translations from Weblate, except skip zh_Hans Cherry-picked from 54664523686a1fa599bab644b515a380853d90e1, which was in the v0.0.29 preview beta. The cherry-pick required fixup only in rerunning `tools/check l10n --fix`, which changed the set of English fallback strings included in the new `de` translation. This update required adjustment; straight from the Weblate branch, l10n generation (`tools/check l10n --fix`) failed: Arb file for a fallback, zh, does not exist, even though the following locale(s) exist: [zh_Hans]. When locales specify a script code or country code, a base locale (without the script code or country code) should exist as the fallback. Please create a {fileName}_zh.arb file. The app_zh_Hans.arb file had only two translations in it; it exists at the moment precisely in order to test this sort of workflow. For the release, I just removed it and reran generation. (We'll include it properly in a future change.) --- assets/l10n/app_de.arb | 1 + assets/l10n/app_en_GB.arb | 6 + assets/l10n/app_pl.arb | 10 + assets/l10n/app_uk.arb | 14 +- lib/generated/l10n/zulip_localizations.dart | 18 + .../l10n/zulip_localizations_de.dart | 770 ++++++++++++++++++ .../l10n/zulip_localizations_en.dart | 9 + .../l10n/zulip_localizations_pl.dart | 2 +- .../l10n/zulip_localizations_uk.dart | 14 +- 9 files changed, 829 insertions(+), 15 deletions(-) create mode 100644 assets/l10n/app_de.arb create mode 100644 assets/l10n/app_en_GB.arb create mode 100644 lib/generated/l10n/zulip_localizations_de.dart diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_de.arb @@ -0,0 +1 @@ +{} diff --git a/assets/l10n/app_en_GB.arb b/assets/l10n/app_en_GB.arb new file mode 100644 index 0000000000..3e9859203f --- /dev/null +++ b/assets/l10n/app_en_GB.arb @@ -0,0 +1,6 @@ +{ + "topicValidationErrorMandatoryButEmpty": "Topics are required in this organisation.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + } +} diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index da8d2c8664..af51066e19 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1006,5 +1006,15 @@ "example": "4.0" } } + }, + "composeBoxEnterTopicOrSkipHintText": "Wpisz tytuł wątku (pomiń aby uzyskać “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } } } diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb index b851404ad8..686b1345a6 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -7,7 +7,7 @@ "@actionSheetOptionFollowTopic": { "description": "Label for following a topic on action sheet." }, - "actionSheetOptionUnstarMessage": "Зняти позначку зірочки з повідомлення", + "actionSheetOptionUnstarMessage": "Зняти позначку зірки з повідомлення", "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, @@ -51,11 +51,11 @@ "@errorSharingFailed": { "description": "Error message when sharing a message failed." }, - "errorStarMessageFailedTitle": "Не вдалося позначити повідомлення зірочкою", + "errorStarMessageFailedTitle": "Не вдалося позначити повідомлення зіркою", "@errorStarMessageFailedTitle": { "description": "Error title when starring a message failed." }, - "errorUnstarMessageFailedTitle": "Не вдалося зняти позначку зірочки з повідомлення", + "errorUnstarMessageFailedTitle": "Не вдалося зняти позначку зірки з повідомлення", "@errorUnstarMessageFailedTitle": { "description": "Error title when unstarring a message failed." }, @@ -157,7 +157,7 @@ "@permissionsDeniedReadExternalStorage": { "description": "Message for dialog asking the user to grant permissions for external storage read access." }, - "actionSheetOptionStarMessage": "Позначити повідомлення зірочкою", + "actionSheetOptionStarMessage": "Вибрати повідомлення", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." }, @@ -315,7 +315,7 @@ } } }, - "composeBoxGroupDmContentHint": "Група повідомлень", + "composeBoxGroupDmContentHint": "Написати групі", "@composeBoxGroupDmContentHint": { "description": "Hint text for content input when sending a message to a group." }, @@ -691,7 +691,7 @@ "@mentionsPageTitle": { "description": "Page title for the 'Mentions' message view." }, - "starredMessagesPageTitle": "Повідомлення, позначені зірочкою", + "starredMessagesPageTitle": "Вибрані повідомлення", "@starredMessagesPageTitle": { "description": "Page title for the 'Starred messages' message view." }, @@ -955,7 +955,7 @@ "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." }, - "combinedFeedPageTitle": "Комбінована стрічка", + "combinedFeedPageTitle": "Об'єднана стрічка", "@combinedFeedPageTitle": { "description": "Page title for the 'Combined feed' message view." }, diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 75b70a0377..ecb0eee16a 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart' as intl; import 'zulip_localizations_ar.dart'; +import 'zulip_localizations_de.dart'; import 'zulip_localizations_en.dart'; import 'zulip_localizations_ja.dart'; import 'zulip_localizations_nb.dart'; @@ -102,6 +103,8 @@ abstract class ZulipLocalizations { static const List supportedLocales = [ Locale('en'), Locale('ar'), + Locale('de'), + Locale('en', 'GB'), Locale('ja'), Locale('nb'), Locale('pl'), @@ -1409,6 +1412,7 @@ class _ZulipLocalizationsDelegate @override bool isSupported(Locale locale) => [ 'ar', + 'de', 'en', 'ja', 'nb', @@ -1423,10 +1427,24 @@ class _ZulipLocalizationsDelegate } ZulipLocalizations lookupZulipLocalizations(Locale locale) { + // Lookup logic when language+country codes are specified. + switch (locale.languageCode) { + case 'en': + { + switch (locale.countryCode) { + case 'GB': + return ZulipLocalizationsEnGb(); + } + break; + } + } + // Lookup logic when only language code is specified. switch (locale.languageCode) { case 'ar': return ZulipLocalizationsAr(); + case 'de': + return ZulipLocalizationsDe(); case 'en': return ZulipLocalizationsEn(); case 'ja': diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart new file mode 100644 index 0000000000..08d09bb3c4 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -0,0 +1,770 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class ZulipLocalizationsDe extends ZulipLocalizations { + ZulipLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get aboutPageTitle => 'About Zulip'; + + @override + String get aboutPageAppVersion => 'App version'; + + @override + String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + + @override + String get aboutPageTapToView => 'Tap to view'; + + @override + String get chooseAccountPageTitle => 'Choose account'; + + @override + String get settingsPageTitle => 'Settings'; + + @override + String get switchAccountButton => 'Switch account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Your account at $url is taking a while to load.'; + } + + @override + String get tryAnotherAccountButton => 'Try another account'; + + @override + String get chooseAccountPageLogOutButton => 'Log out'; + + @override + String get logOutConfirmationDialogTitle => 'Log out?'; + + @override + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Log out'; + + @override + String get chooseAccountButtonAddAnAccount => 'Add an account'; + + @override + String get profileButtonSendDirectMessage => 'Send direct message'; + + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + + @override + String get permissionsNeededTitle => 'Permissions needed'; + + @override + String get permissionsNeededOpenSettings => 'Open settings'; + + @override + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionMuteTopic => 'Mute topic'; + + @override + String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + + @override + String get actionSheetOptionFollowTopic => 'Follow topic'; + + @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'; + + @override + String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + + @override + String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + + @override + String get actionSheetOptionShare => 'Share'; + + @override + String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + + @override + String get actionSheetOptionStarMessage => 'Star message'; + + @override + String get actionSheetOptionUnstarMessage => 'Unstar message'; + + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + + @override + String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + + @override + String get errorAccountLoggedInTitle => 'Account already logged in'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'The account $email at $server is already in your list of accounts.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Could not fetch message source.'; + + @override + String get errorCopyingFailed => 'Copying failed'; + + @override + String errorFailedToUploadFileTitle(String filename) { + 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( + num, + locale: localeName, + other: '$num files are', + one: 'File is', + ); + return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Files', + one: 'File', + ); + return '$_temp0 too large'; + } + + @override + String get errorLoginInvalidInputTitle => 'Invalid input'; + + @override + String get errorLoginFailedTitle => 'Login failed'; + + @override + String get errorMessageNotSent => 'Message not sent'; + + @override + String get errorMessageEditNotSaved => 'Message not saved'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Failed to connect to server:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Could not connect'; + + @override + String get errorMessageDoesNotSeemToExist => + 'That message does not seem to exist.'; + + @override + String get errorQuotationFailed => 'Quotation failed'; + + @override + String errorServerMessage(String message) { + return 'The server said:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Error connecting to Zulip. Retrying…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Error handling a Zulip event. Retrying connection…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Link could not be opened: $url'; + } + + @override + String get errorMuteTopicFailed => 'Failed to mute topic'; + + @override + String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + + @override + String get errorFollowTopicFailed => 'Failed to follow topic'; + + @override + String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + + @override + String get errorSharingFailed => 'Sharing failed'; + + @override + String get errorStarMessageFailedTitle => 'Failed to star message'; + + @override + String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + + @override + String get successLinkCopied => 'Link copied'; + + @override + String get successMessageTextCopied => 'Message text copied'; + + @override + String get successMessageLinkCopied => 'Message link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + + @override + String get composeBoxAttachFilesTooltip => 'Attach files'; + + @override + String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + + @override + String get composeBoxGenericContentHint => 'Type a message'; + + @override + String composeBoxDmContentHint(String user) { + return 'Message @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Message group'; + + @override + String get composeBoxSelfDmContentHint => 'Jot down something'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Preparing…'; + + @override + String get composeBoxSendTooltip => 'Send'; + + @override + String get unknownChannelName => '(unknown channel)'; + + @override + String get composeBoxTopicHintText => 'Topic'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Uploading $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + + @override + String get unknownUserName => '(unknown user)'; + + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'You and $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + + @override + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; + + @override + String get contentValidationErrorEmpty => 'You have nothing to send!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogContinue => 'Continue'; + + @override + String get dialogClose => 'Close'; + + @override + String get errorDialogLearnMore => 'Learn more'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => 'Error'; + + @override + String get snackBarDetails => 'Details'; + + @override + String get lightboxCopyLinkTooltip => 'Copy link'; + + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + + @override + String get loginPageTitle => 'Log in'; + + @override + String get loginFormSubmitLabel => 'Log in'; + + @override + String get loginMethodDivider => 'OR'; + + @override + String signInWithFoo(String method) { + return 'Sign in with $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Add an account'; + + @override + String get loginServerUrlLabel => 'Your Zulip server URL'; + + @override + String get loginHidePassword => 'Hide password'; + + @override + String get loginEmailLabel => 'Email address'; + + @override + String get loginErrorMissingEmail => 'Please enter your email.'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginErrorMissingPassword => 'Please enter your password.'; + + @override + String get loginUsernameLabel => 'Username'; + + @override + String get loginErrorMissingUsername => 'Please enter your username.'; + + @override + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + + @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.'; + + @override + String get errorNetworkRequestFailed => 'Network request failed'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Server gave malformed response; HTTP status $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Server gave malformed response; HTTP status $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Network request failed: HTTP status $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Unable to play the video.'; + + @override + String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Mark all messages as read'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as read.'; + } + + @override + String get markAsReadInProgress => 'Marking messages as read…'; + + @override + String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as unread.'; + } + + @override + String get markAsUnreadInProgress => 'Marking messages as unread…'; + + @override + String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + + @override + String get today => 'Today'; + + @override + String get yesterday => 'Yesterday'; + + @override + String get userRoleOwner => 'Owner'; + + @override + String get userRoleAdministrator => 'Administrator'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Member'; + + @override + String get userRoleGuest => 'Guest'; + + @override + String get userRoleUnknown => 'Unknown'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get recentDmConversationsPageTitle => 'Direct messages'; + + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + + @override + String get combinedFeedPageTitle => 'Combined feed'; + + @override + String get mentionsPageTitle => 'Mentions'; + + @override + String get starredMessagesPageTitle => 'Starred messages'; + + @override + String get channelsPageTitle => 'Channels'; + + @override + String get mainMenuMyProfile => 'My profile'; + + @override + String get channelFeedButtonTooltip => 'Channel feed'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers others', + one: '1 other', + ); + return '$senderFullName to you and $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + + @override + String get notifSelfUser => 'You'; + + @override + String get reactedEmojiSelfUser => 'You'; + + @override + String onePersonTyping(String typist) { + return '$typist is typing…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist and $otherTypist are typing…'; + } + + @override + String get manyPeopleTyping => 'Several people are typing…'; + + @override + String get wildcardMentionAll => 'all'; + + @override + String get wildcardMentionEveryone => 'everyone'; + + @override + String get wildcardMentionChannel => 'channel'; + + @override + String get wildcardMentionStream => 'stream'; + + @override + String get wildcardMentionTopic => 'topic'; + + @override + String get wildcardMentionChannelDescription => 'Notify channel'; + + @override + String get wildcardMentionStreamDescription => 'Notify stream'; + + @override + String get wildcardMentionAllDmDescription => 'Notify recipients'; + + @override + String get wildcardMentionTopicDescription => 'Notify topic'; + + @override + String get messageIsEditedLabel => 'EDITED'; + + @override + String get messageIsMovedLabel => 'MOVED'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + + @override + String get pollWidgetQuestionMissing => 'No question.'; + + @override + String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Failed to open notification'; + + @override + String get errorNotificationOpenAccountMissing => + 'The account associated with this notification no longer exists.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @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 0a746ff634..105162429b 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -768,3 +768,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; } + +/// The translations for English, as used in the United Kingdom (`en_GB`). +class ZulipLocalizationsEnGb extends ZulipLocalizationsEn { + ZulipLocalizationsEnGb() : super('en_GB'); + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organisation.'; +} diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 657e1757d1..a3cb2ee66b 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -370,7 +370,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Wpisz tytuł wątku (pomiń aby uzyskać “$defaultTopicName”)'; } @override diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index a756bdba6a..94fee8825a 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -122,11 +122,11 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionQuoteAndReply => 'Цитата і відповідь'; @override - String get actionSheetOptionStarMessage => 'Позначити повідомлення зірочкою'; + String get actionSheetOptionStarMessage => 'Вибрати повідомлення'; @override String get actionSheetOptionUnstarMessage => - 'Зняти позначку зірочки з повідомлення'; + 'Зняти позначку зірки з повідомлення'; @override String get actionSheetOptionEditMessage => 'Edit message'; @@ -270,11 +270,11 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorStarMessageFailedTitle => - 'Не вдалося позначити повідомлення зірочкою'; + 'Не вдалося позначити повідомлення зіркою'; @override String get errorUnstarMessageFailedTitle => - 'Не вдалося зняти позначку зірочки з повідомлення'; + 'Не вдалося зняти позначку зірки з повідомлення'; @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; @@ -348,7 +348,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get composeBoxGroupDmContentHint => 'Група повідомлень'; + String get composeBoxGroupDmContentHint => 'Написати групі'; @override String get composeBoxSelfDmContentHint => 'Занотувати щось'; @@ -627,13 +627,13 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get recentDmConversationsSectionHeader => 'Особисті повідомлення'; @override - String get combinedFeedPageTitle => 'Комбінована стрічка'; + String get combinedFeedPageTitle => 'Об\'єднана стрічка'; @override String get mentionsPageTitle => 'Згадки'; @override - String get starredMessagesPageTitle => 'Повідомлення, позначені зірочкою'; + String get starredMessagesPageTitle => 'Вибрані повідомлення'; @override String get channelsPageTitle => 'Канали'; From 63f712aaa9a9e66c4c3d87779dd88e31284bd9a3 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 20 May 2025 19:08:07 +0200 Subject: [PATCH 073/290] l10n: Update (squashed) translations from Weblate and resolve conflicts This squashes all the pending commits from hosted Weblate that are not in our GitHub repo. This also resolves the merge conflicts between the two remotes, and has `tools/check --fix l10n` rerun. CZO discussion: https://chat.zulip.org/#narrow/channel/58-translation/topic/Weblate.20conflict.20resolution/near/2178307 --- assets/l10n/app_pl.arb | 62 ++++++++++++++++++- .../l10n/zulip_localizations_pl.dart | 34 +++++----- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index af51066e19..8de9527def 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -107,7 +107,7 @@ } } }, - "errorCouldNotFetchMessageSource": "Nie można uzyskać źródłowej wiadomości", + "errorCouldNotFetchMessageSource": "Nie można uzyskać źródłowej wiadomości.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -377,11 +377,11 @@ "@topicValidationErrorMandatoryButEmpty": { "description": "Topic validation error when topic is required but was empty." }, - "errorInvalidResponse": "Nieprawidłowa odpowiedź serwera", + "errorInvalidResponse": "Nieprawidłowa odpowiedź serwera.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, - "errorVideoPlayerFailed": "Nie da rady odtworzyć wideo", + "errorVideoPlayerFailed": "Nie da rady odtworzyć wideo.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, @@ -1016,5 +1016,61 @@ "example": "general chat" } } + }, + "actionSheetOptionEditMessage": "Zmień wiadomość", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorMessageEditNotSaved": "Nie zapisano wiadomości", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorCouldNotEditMessageTitle": "Nie można zmienić wiadomości", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxBannerLabelEditMessage": "Zmień wiadomość", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressMessage": "Operacja zmiany w toku. Zaczekaj na jej zakończenie.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "ZAPIS ZMIANY…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "NIE ZAPISANO ZMIANY", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Czy chcesz przerwać szykowanie wpisu?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogMessage": "Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.", + "@discardDraftConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Odrzuć", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxBannerButtonCancel": "Anuluj", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "preparingEditMessageContentInput": "Przygotowywanie…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "editAlreadyInProgressTitle": "Nie udało się zapisać zmiany", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "composeBoxBannerButtonSave": "Zapisz", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." } } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index a3cb2ee66b..26b4b7e306 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -128,7 +128,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionUnstarMessage => 'Odbierz gwiazdkę'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'Zmień wiadomość'; @override String get actionSheetOptionMarkTopicAsRead => @@ -150,7 +150,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Nie można uzyskać źródłowej wiadomości'; + 'Nie można uzyskać źródłowej wiadomości.'; @override String get errorCopyingFailed => 'Nie udało się skopiować'; @@ -201,7 +201,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorMessageNotSent => 'Nie wysłano wiadomości'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Nie zapisano wiadomości'; @override String errorLoginCouldNotConnect(String url) { @@ -276,7 +276,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Odebranie gwiazdki bez powodzenia'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => 'Nie można zmienić wiadomości'; @override String get successLinkCopied => 'Skopiowano odnośnik'; @@ -296,37 +296,37 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Nie masz uprawnień do dodawania wpisów w tym kanale.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Zmień wiadomość'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Anuluj'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Zapisz'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => 'Nie udało się zapisać zmiany'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Operacja zmiany w toku. Zaczekaj na jej zakończenie.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'ZAPIS ZMIANY…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'NIE ZAPISANO ZMIANY'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + 'Czy chcesz przerwać szykowanie wpisu?'; @override String get discardDraftConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; @override String get composeBoxAttachFilesTooltip => 'Dołącz pliki'; @@ -357,7 +357,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Przygotowywanie…'; @override String get composeBoxSendTooltip => 'Wyślij'; @@ -511,7 +511,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera'; + String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera.'; @override String get errorNetworkRequestFailed => 'Dostęp do sieci bez powodzenia'; @@ -532,7 +532,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Nie da rady odtworzyć wideo'; + String get errorVideoPlayerFailed => 'Nie da rady odtworzyć wideo.'; @override String get serverUrlValidationErrorEmpty => 'Proszę podaj URL.'; From 1a157a9fc2fcaef9dba8120c51b4dbed7276104a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 22 May 2025 11:40:37 -0700 Subject: [PATCH 074/290] android [nfc]: Add a TODO(#855) for removing a lint suppression Added in 3013e0725, a file_picker package upgrade; #855 is for dropping the file_picker dependency. --- android/app/lint-baseline.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml index 006b010637..a3d1aeac5c 100644 --- a/android/app/lint-baseline.xml +++ b/android/app/lint-baseline.xml @@ -1,6 +1,7 @@ + From 15f3f5929daf15f614f72559c44a0574d0168003 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 22 May 2025 13:41:31 -0700 Subject: [PATCH 075/290] android build: Disable lint AndroidGradlePluginVersion We're successfully managing our Gradle and AGP upgrades without this lint rule, and it's annoying that it fails our CI builds. Discussion: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/CI.20fail.3A.20lint.20on.20Gradle.20version.3F/near/2179323 --- android/app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/build.gradle b/android/app/build.gradle index 640d7a1fdb..84ad671523 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -78,6 +78,7 @@ android { checkAllWarnings = true warningsAsErrors = true baseline = file("lint-baseline.xml") + disable += ['AndroidGradlePluginVersion'] } } From e5f703a52ee62dcda41e49e47b3678c187a586b2 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 30 Jan 2025 14:39:21 -0500 Subject: [PATCH 076/290] api: Add savedSnippets to initial snapshot --- lib/api/model/initial_snapshot.dart | 3 +++ lib/api/model/initial_snapshot.g.dart | 5 +++++ lib/api/model/model.dart | 24 ++++++++++++++++++++++++ lib/api/model/model.g.dart | 15 +++++++++++++++ test/example_data.dart | 2 ++ 5 files changed, 49 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 054230a256..f4cc2fe5fc 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -48,6 +48,8 @@ class InitialSnapshot { final List recentPrivateConversations; + final List? savedSnippets; // TODO(server-10) + final List subscriptions; final UnreadMessagesSnapshot unreadMsgs; @@ -132,6 +134,7 @@ class InitialSnapshot { required this.serverTypingStartedWaitPeriodMilliseconds, required this.realmEmoji, required this.recentPrivateConversations, + required this.savedSnippets, required this.subscriptions, required this.unreadMsgs, required this.streams, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 570d7c2bba..36afb0a39f 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -45,6 +45,10 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['recent_private_conversations'] as List) .map((e) => RecentDmConversation.fromJson(e as Map)) .toList(), + savedSnippets: + (json['saved_snippets'] as List?) + ?.map((e) => SavedSnippet.fromJson(e as Map)) + .toList(), subscriptions: (json['subscriptions'] as List) .map((e) => Subscription.fromJson(e as Map)) @@ -128,6 +132,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => instance.serverTypingStartedWaitPeriodMilliseconds, 'realm_emoji': instance.realmEmoji, 'recent_private_conversations': instance.recentPrivateConversations, + 'saved_snippets': instance.savedSnippets, 'subscriptions': instance.subscriptions, 'unread_msgs': instance.unreadMsgs, 'streams': instance.streams, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index a2874c4c44..131a51991b 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -311,6 +311,30 @@ enum UserRole{ } } +/// An item in `saved_snippets` from the initial snapshot. +/// +/// For docs, search for "saved_snippets:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippet { + SavedSnippet({ + required this.id, + required this.title, + required this.content, + required this.dateCreated, + }); + + final int id; + final String title; + final String content; + final int dateCreated; + + factory SavedSnippet.fromJson(Map json) => + _$SavedSnippetFromJson(json); + + Map toJson() => _$SavedSnippetToJson(this); +} + /// As in `streams` in the initial snapshot. /// /// Not called `Stream` because dart:async uses that name. diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index cddf78beb0..67fc606031 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -162,6 +162,21 @@ Map _$ProfileFieldUserDataToJson( 'rendered_value': instance.renderedValue, }; +SavedSnippet _$SavedSnippetFromJson(Map json) => SavedSnippet( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + content: json['content'] as String, + dateCreated: (json['date_created'] as num).toInt(), +); + +Map _$SavedSnippetToJson(SavedSnippet instance) => + { + 'id': instance.id, + 'title': instance.title, + 'content': instance.content, + 'date_created': instance.dateCreated, + }; + ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, diff --git a/test/example_data.dart b/test/example_data.dart index c80fcf4528..c437a0a10a 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -959,6 +959,7 @@ InitialSnapshot initialSnapshot({ int? serverTypingStartedWaitPeriodMilliseconds, Map? realmEmoji, List? recentPrivateConversations, + List? savedSnippets, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List? streams, @@ -994,6 +995,7 @@ InitialSnapshot initialSnapshot({ serverTypingStartedWaitPeriodMilliseconds ?? 10000, realmEmoji: realmEmoji ?? {}, recentPrivateConversations: recentPrivateConversations ?? [], + savedSnippets: savedSnippets ?? [], subscriptions: subscriptions ?? [], // TODO add subscriptions to default unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default From 477a7775979112633dd579de5081045c8524730d Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 11 Mar 2025 14:31:34 -0400 Subject: [PATCH 077/290] api: Add saved_snippets events --- lib/api/model/events.dart | 69 +++++++++++++++++++++++++++++++++++++ lib/api/model/events.g.dart | 49 ++++++++++++++++++++++++++ lib/model/store.dart | 4 +++ 3 files changed, 122 insertions(+) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 0479b0428f..62789333e1 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -37,6 +37,13 @@ sealed class Event { case 'update': return RealmUserUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'saved_snippets': + switch (json['op'] as String) { + case 'add': return SavedSnippetsAddEvent.fromJson(json); + case 'update': return SavedSnippetsUpdateEvent.fromJson(json); + case 'remove': return SavedSnippetsRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'stream': switch (json['op'] as String) { case 'create': return ChannelCreateEvent.fromJson(json); @@ -336,6 +343,68 @@ class RealmUserUpdateEvent extends RealmUserEvent { Map toJson() => _$RealmUserUpdateEventToJson(this); } +/// A Zulip event of type `saved_snippets`: https://zulip.com/api/get-events#saved_snippets-add +sealed class SavedSnippetsEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'saved_snippets'; + + String get op; + + SavedSnippetsEvent({required super.id}); +} + +/// A [SavedSnippetsEvent] with op `add`: https://zulip.com/api/get-events#saved_snippets-add +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsAddEvent extends SavedSnippetsEvent { + @override + String get op => 'add'; + + final SavedSnippet savedSnippet; + + SavedSnippetsAddEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsAddEvent.fromJson(Map json) => + _$SavedSnippetsAddEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsAddEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `update`: https://zulip.com/api/get-events#saved_snippets-update +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsUpdateEvent extends SavedSnippetsEvent { + @override + String get op => 'update'; + + final SavedSnippet savedSnippet; + + SavedSnippetsUpdateEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsUpdateEvent.fromJson(Map json) => + _$SavedSnippetsUpdateEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsUpdateEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `remove`: https://zulip.com/api/get-events#saved_snippets-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsRemoveEvent extends SavedSnippetsEvent { + @override + String get op => 'remove'; + + final int savedSnippetId; + + SavedSnippetsRemoveEvent({required super.id, required this.savedSnippetId}); + + factory SavedSnippetsRemoveEvent.fromJson(Map json) => + _$SavedSnippetsRemoveEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsRemoveEventToJson(this); +} + /// A Zulip event of type `stream`. /// /// The corresponding API docs are in several places for diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 35206d77b9..94fe288150 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -203,6 +203,55 @@ Json? _$JsonConverterToJson( Json? Function(Value value) toJson, ) => value == null ? null : toJson(value); +SavedSnippetsAddEvent _$SavedSnippetsAddEventFromJson( + Map json, +) => SavedSnippetsAddEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsAddEventToJson( + SavedSnippetsAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsUpdateEvent _$SavedSnippetsUpdateEventFromJson( + Map json, +) => SavedSnippetsUpdateEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsUpdateEventToJson( + SavedSnippetsUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsRemoveEvent _$SavedSnippetsRemoveEventFromJson( + Map json, +) => SavedSnippetsRemoveEvent( + id: (json['id'] as num).toInt(), + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$SavedSnippetsRemoveEventToJson( + SavedSnippetsRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet_id': instance.savedSnippetId, +}; + ChannelCreateEvent _$ChannelCreateEventFromJson(Map json) => ChannelCreateEvent( id: (json['id'] as num).toInt(), diff --git a/lib/model/store.dart b/lib/model/store.dart index 297e4300bc..de8c28e79a 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -871,6 +871,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor autocompleteViewManager.handleRealmUserUpdateEvent(event); notifyListeners(); + case SavedSnippetsEvent(): + // TODO handle + break; + case ChannelEvent(): assert(debugLog("server event: stream/${event.op}")); _channels.handleChannelEvent(event); From ab2bbbf39587aa375462369d5d419fec7e1f9be8 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 20 May 2025 16:38:41 -0400 Subject: [PATCH 078/290] model: Add PerAccountStore.savedSnippets, updating with events --- lib/model/saved_snippet.dart | 38 ++++++++++++++++++++++++++ lib/model/store.dart | 16 +++++++++-- test/api/model/model_checks.dart | 7 +++++ test/example_data.dart | 22 +++++++++++++++ test/model/saved_snippet_test.dart | 44 ++++++++++++++++++++++++++++++ test/model/store_checks.dart | 1 + 6 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 lib/model/saved_snippet.dart create mode 100644 test/model/saved_snippet_test.dart diff --git a/lib/model/saved_snippet.dart b/lib/model/saved_snippet.dart new file mode 100644 index 0000000000..59c8347591 --- /dev/null +++ b/lib/model/saved_snippet.dart @@ -0,0 +1,38 @@ +import 'package:collection/collection.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import 'store.dart'; + +mixin SavedSnippetStore { + Map get savedSnippets; +} + +class SavedSnippetStoreImpl extends PerAccountStoreBase with SavedSnippetStore { + SavedSnippetStoreImpl({ + required super.core, + required Iterable savedSnippets, + }) : _savedSnippets = { + for (final savedSnippet in savedSnippets) + savedSnippet.id: savedSnippet, + }; + + @override + late Map savedSnippets = UnmodifiableMapView(_savedSnippets); + final Map _savedSnippets; + + void handleSavedSnippetsEvent(SavedSnippetsEvent event) { + switch (event) { + case SavedSnippetsAddEvent(:final savedSnippet): + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsUpdateEvent(:final savedSnippet): + assert(_savedSnippets[savedSnippet.id]!.dateCreated + == savedSnippet.dateCreated); // TODO(log) + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsRemoveEvent(:final savedSnippetId): + _savedSnippets.remove(savedSnippetId); + } + } +} diff --git a/lib/model/store.dart b/lib/model/store.dart index de8c28e79a..240e3ab4e4 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -29,6 +29,7 @@ import 'message_list.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; import 'channel.dart'; +import 'saved_snippet.dart'; import 'settings.dart'; import 'typing_status.dart'; import 'unreads.dart'; @@ -431,7 +432,7 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { /// This class does not attempt to poll an event queue /// to keep the data up to date. For that behavior, see /// [UpdateMachine]. -class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, UserStore, ChannelStore, MessageStore { +class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, SavedSnippetStore, UserStore, ChannelStore, MessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// /// The global store must already have been updated with @@ -486,6 +487,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor emoji: EmojiStoreImpl( core: core, allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, + savedSnippets: SavedSnippetStoreImpl( + core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), typingNotifier: TypingNotifier( core: core, typingStoppedWaitPeriod: Duration( @@ -524,6 +527,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.emailAddressVisibility, required EmojiStoreImpl emoji, required this.userSettings, + required SavedSnippetStoreImpl savedSnippets, required this.typingNotifier, required UserStoreImpl users, required this.typingStatus, @@ -534,6 +538,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.recentSenders, }) : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, _emoji = emoji, + _savedSnippets = savedSnippets, _users = users, _channels = channels, _messages = messages; @@ -624,6 +629,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor final UserSettings? userSettings; // TODO(server-5) + @override + Map get savedSnippets => _savedSnippets.savedSnippets; + final SavedSnippetStoreImpl _savedSnippets; + final TypingNotifier typingNotifier; //////////////////////////////// @@ -872,8 +881,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor notifyListeners(); case SavedSnippetsEvent(): - // TODO handle - break; + assert(debugLog('server event: saved_snippets/${event.op}')); + _savedSnippets.handleSavedSnippetsEvent(event); + notifyListeners(); case ChannelEvent(): assert(debugLog("server event: stream/${event.op}")); diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 1a17f70f60..b90238ae35 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -21,6 +21,13 @@ extension UserChecks on Subject { Subject get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot'); } +extension SavedSnippetChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get title => has((x) => x.title, 'title'); + Subject get content => has((x) => x.content, 'content'); + Subject get dateCreated => has((x) => x.dateCreated, 'dateCreated'); +} + extension ZulipStreamChecks on Subject { } diff --git a/test/example_data.dart b/test/example_data.dart index c437a0a10a..d803196269 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -286,6 +286,28 @@ final User thirdUser = user(fullName: 'Third User'); final User fourthUser = user(fullName: 'Fourth User'); +//////////////////////////////////////////////////////////////// +// Data attached to the self-account on the realm +// + +int _nextSavedSnippetId() => _lastSavedSnippetId++; +int _lastSavedSnippetId = 1; + +SavedSnippet savedSnippet({ + int? id, + String? title, + String? content, + int? dateCreated, +}) { + _checkPositive(id, 'saved snippet ID'); + return SavedSnippet( + id: id ?? _nextSavedSnippetId(), + title: title ?? 'A saved snippet', + content: content ?? 'foo bar baz', + dateCreated: dateCreated ?? 1234567890, // TODO generate timestamp + ); +} + //////////////////////////////////////////////////////////////// // Streams and subscriptions. // diff --git a/test/model/saved_snippet_test.dart b/test/model/saved_snippet_test.dart new file mode 100644 index 0000000000..3c6756f977 --- /dev/null +++ b/test/model/saved_snippet_test.dart @@ -0,0 +1,44 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; + +import '../api/model/model_checks.dart'; +import '../example_data.dart' as eg; +import 'store_checks.dart'; + +void main() { + test('handleSavedSnippetsEvent', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + savedSnippets: [eg.savedSnippet(id: 101)])); + check(store).savedSnippets.values.single.id.equals(101); + + await store.handleEvent(SavedSnippetsAddEvent(id: 1, + savedSnippet: eg.savedSnippet( + id: 102, + title: 'foo title', + content: 'foo content', + ))); + check(store).savedSnippets.values.deepEquals(>[ + (it) => it.isA().id.equals(101), + (it) => it.isA()..id.equals(102) + ..title.equals('foo title') + ..content.equals('foo content') + ]); + + await store.handleEvent(SavedSnippetsRemoveEvent(id: 1, savedSnippetId: 101)); + check(store).savedSnippets.values.single.id.equals(102); + + await store.handleEvent(SavedSnippetsUpdateEvent(id: 1, + savedSnippet: eg.savedSnippet( + id: 102, + title: 'bar title', + content: 'bar content', + dateCreated: store.savedSnippets.values.single.dateCreated, + ))); + check(store).savedSnippets.values.single + ..id.equals(102) + ..title.equals('bar title') + ..content.equals('bar content'); + }); +} diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 32379a6f06..93e24dffdd 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -56,6 +56,7 @@ extension PerAccountStoreChecks on Subject { Subject get account => has((x) => x.account, 'account'); Subject get selfUserId => has((x) => x.selfUserId, 'selfUserId'); Subject get userSettings => has((x) => x.userSettings, 'userSettings'); + Subject> get savedSnippets => has((x) => x.savedSnippets, 'savedSnippets'); Subject> get streams => has((x) => x.streams, 'streams'); Subject> get streamsByName => has((x) => x.streamsByName, 'streamsByName'); Subject> get subscriptions => has((x) => x.subscriptions, 'subscriptions'); From 2a6c8afb0256763c489c918ffd6851a59dc4f36e Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Mar 2025 14:23:52 -0500 Subject: [PATCH 079/290] api: Add createSavedSnippet route --- lib/api/route/saved_snippets.dart | 31 +++++++++++++++++++++++++ lib/api/route/saved_snippets.g.dart | 19 +++++++++++++++ test/api/route/route_checks.dart | 4 ++++ test/api/route/saved_snippets_test.dart | 27 +++++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 lib/api/route/saved_snippets.dart create mode 100644 lib/api/route/saved_snippets.g.dart create mode 100644 test/api/route/saved_snippets_test.dart diff --git a/lib/api/route/saved_snippets.dart b/lib/api/route/saved_snippets.dart new file mode 100644 index 0000000000..047a35051e --- /dev/null +++ b/lib/api/route/saved_snippets.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../core.dart'; + +part 'saved_snippets.g.dart'; + +/// https://zulip.com/api/create-saved-snippet +Future createSavedSnippet(ApiConnection connection, { + required String title, + required String content, +}) { + assert(connection.zulipFeatureLevel! >= 297); // TODO(server-10) + return connection.post('createSavedSnippet', CreateSavedSnippetResult.fromJson, 'saved_snippets', { + 'title': RawParameter(title), + 'content': RawParameter(content), + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class CreateSavedSnippetResult { + final int savedSnippetId; + + CreateSavedSnippetResult({ + required this.savedSnippetId, + }); + + factory CreateSavedSnippetResult.fromJson(Map json) => + _$CreateSavedSnippetResultFromJson(json); + + Map toJson() => _$CreateSavedSnippetResultToJson(this); +} diff --git a/lib/api/route/saved_snippets.g.dart b/lib/api/route/saved_snippets.g.dart new file mode 100644 index 0000000000..aeb3c2a6c5 --- /dev/null +++ b/lib/api/route/saved_snippets.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'saved_snippets.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateSavedSnippetResult _$CreateSavedSnippetResultFromJson( + Map json, +) => CreateSavedSnippetResult( + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$CreateSavedSnippetResultToJson( + CreateSavedSnippetResult instance, +) => {'saved_snippet_id': instance.savedSnippetId}; diff --git a/test/api/route/route_checks.dart b/test/api/route/route_checks.dart index 6d310ab200..1ecd90e9c8 100644 --- a/test/api/route/route_checks.dart +++ b/test/api/route/route_checks.dart @@ -1,8 +1,12 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; extension SendMessageResultChecks on Subject { Subject get id => has((e) => e.id, 'id'); } +extension CreateSavedSnippetResultChecks on Subject { + Subject get savedSnippetId => has((e) => e.savedSnippetId, 'savedSnippetId'); +} // TODO add similar extensions for other classes in api/route/*.dart diff --git a/test/api/route/saved_snippets_test.dart b/test/api/route/saved_snippets_test.dart new file mode 100644 index 0000000000..3eeccbde8b --- /dev/null +++ b/test/api/route/saved_snippets_test.dart @@ -0,0 +1,27 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; +import 'route_checks.dart'; + +void main() { + test('smoke', () async { + return FakeApiConnection.with_((connection) async { + connection.prepare( + json: CreateSavedSnippetResult(savedSnippetId: 123).toJson()); + final result = await createSavedSnippet(connection, + title: 'test saved snippet', content: 'content'); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/saved_snippets') + ..bodyFields.deepEquals({ + 'title': 'test saved snippet', + 'content': 'content', + }); + check(result).savedSnippetId.equals(123); + }); + }); +} From b806f9d8065d613cdb788ad9baf79c4a8452507a Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 19 May 2025 15:01:20 -0400 Subject: [PATCH 080/290] message [nfc]: Add _disposed flag; check it This change should have no user-facing effect. The one spot where we have an `if (_disposed)` check in editMessage prevents a state update and a rebuild from happening. This only applies if the store is disposed before the edit request fails, but the MessageListView with the edited message should get rebuilt anyway (through onNewStore) when that happens. --- lib/model/message.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/model/message.dart b/lib/model/message.dart index 2573cfadc6..6266e886b8 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -94,12 +94,16 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @override void registerMessageList(MessageListView view) { + assert(!_disposed); final added = _messageListViews.add(view); assert(added); } @override void unregisterMessageList(MessageListView view) { + // TODO: Add `assert(!_disposed);` here once we ensure [PerAccountStore] is + // only disposed after [MessageListView]s with references to it are + // disposed. See [dispose] for details. final removed = _messageListViews.remove(view); assert(removed); } @@ -122,6 +126,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } } + bool _disposed = false; + void dispose() { // Not disposing the [MessageListView]s here, because they are owned by // (i.e., they get [dispose]d by) the [_MessageListState], including in the @@ -137,10 +143,14 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // [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 + + assert(!_disposed); + _disposed = true; } @override Future sendMessage({required MessageDestination destination, required String content}) { + assert(!_disposed); // TODO implement outbox; see design at // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739 return _apiSendMessage(connection, @@ -152,6 +162,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @override void reconcileMessages(List messages) { + assert(!_disposed); // What to do when some of the just-fetched messages are already known? // This is common and normal: in particular it happens when one message list // overlaps another, e.g. a stream and a topic within it. @@ -185,6 +196,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { required String originalRawContent, required String newContent, }) async { + assert(!_disposed); if (_editMessageRequests.containsKey(messageId)) { throw StateError('an edit request is already in progress'); } @@ -202,6 +214,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } catch (e) { // TODO(log) if e is something unexpected + if (_disposed) return; + final status = _editMessageRequests[messageId]; if (status == null) { // The event actually arrived before this request failed @@ -216,6 +230,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { @override ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + assert(!_disposed); final status = _editMessageRequests.remove(messageId); _notifyMessageListViewsForOneMessage(messageId); if (status == null) { From 17a1a4ce5a3ca11fe403470fad88f700c6aa2488 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 25 Mar 2025 16:32:30 -0400 Subject: [PATCH 081/290] message: Create an outbox message on send; manage its states While we do create outbox messages, there are in no way user-visible changes since the outbox messages don't end up in message list views. We create skeletons for helpers needed from message list view, but don't implement them yet, to make the diff smaller. For testing, similar to TypingNotifier.debugEnable, we add MessageStoreImpl.debugOutboxEnable for tests that do not intend to cover outbox messages. --- lib/model/message.dart | 460 +++++++++++++++++++++++++++- lib/model/message_list.dart | 20 ++ lib/model/store.dart | 8 +- test/api/model/model_checks.dart | 1 + test/example_data.dart | 4 +- test/fake_async_checks.dart | 6 + test/model/message_checks.dart | 9 + test/model/message_test.dart | 332 +++++++++++++++++++- test/model/narrow_test.dart | 81 +++-- test/model/store_test.dart | 5 +- test/widgets/compose_box_test.dart | 13 + test/widgets/message_list_test.dart | 11 +- 12 files changed, 887 insertions(+), 63 deletions(-) create mode 100644 test/fake_async_checks.dart create mode 100644 test/model/message_checks.dart diff --git a/lib/model/message.dart b/lib/model/message.dart index 6266e886b8..3fffdfc4a4 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,11 +1,15 @@ +import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../log.dart'; +import 'binding.dart'; import 'message_list.dart'; import 'store.dart'; @@ -16,6 +20,9 @@ mixin MessageStore { /// All known messages, indexed by [Message.id]. Map get messages; + /// [OutboxMessage]s sent by the user, indexed by [OutboxMessage.localMessageId]. + Map get outboxMessages; + Set get debugMessageListViews; void registerMessageList(MessageListView view); @@ -26,6 +33,15 @@ mixin MessageStore { required String content, }); + /// Remove from [outboxMessages] given the [localMessageId], and return + /// the removed [OutboxMessage]. + /// + /// The outbox message to be taken must exist. + /// + /// The state of the outbox message must be either [OutboxMessageState.failed] + /// or [OutboxMessageState.waitPeriodExpired]. + OutboxMessage takeOutboxMessage(int localMessageId); + /// Reconcile a batch of just-fetched messages with the store, /// mutating the list. /// @@ -78,15 +94,29 @@ class _EditMessageRequestStatus { final String newContent; } -class MessageStoreImpl extends PerAccountStoreBase with MessageStore { - MessageStoreImpl({required super.core}) - // There are no messages in InitialSnapshot, so we don't have - // a use case for initializing MessageStore with nonempty [messages]. - : messages = {}; +class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMessageStore { + MessageStoreImpl({required super.core, required String? realmEmptyTopicDisplayName}) + : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, + // There are no messages in InitialSnapshot, so we don't have + // a use case for initializing MessageStore with nonempty [messages]. + messages = {}; + + /// The display name to use for empty topics. + /// + /// This should only be accessed when FL >= 334, since topics cannot + /// be empty otherwise. + // TODO(server-10) simplify this + String get realmEmptyTopicDisplayName { + assert(zulipFeatureLevel >= 334); + assert(_realmEmptyTopicDisplayName != null); // TODO(log) + return _realmEmptyTopicDisplayName ?? 'general chat'; + } + final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting @override final Map messages; + @override final Set _messageListViews = {}; @override @@ -126,6 +156,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { } } + @override bool _disposed = false; void dispose() { @@ -145,19 +176,24 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893 assert(!_disposed); + _disposeOutboxMessages(); _disposed = true; } @override Future sendMessage({required MessageDestination destination, required String content}) { assert(!_disposed); - // TODO implement outbox; see design at - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739 - return _apiSendMessage(connection, - destination: destination, - content: content, - readBySender: true, - ); + if (!debugOutboxEnable) { + return _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true); + } + return _outboxSendMessage( + destination: destination, content: content, + // TODO move [TopicName.processLikeServer] to a substore, eliminating this + // see https://github.com/zulip/zulip-flutter/pull/1472#discussion_r2099069276 + realmEmptyTopicDisplayName: _realmEmptyTopicDisplayName); } @override @@ -257,6 +293,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // See [fetchedMessages] for reasoning. messages[event.message.id] = event.message; + _handleMessageEventOutbox(event); + for (final view in _messageListViews) { view.handleMessageEvent(event); } @@ -450,4 +488,402 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // [Poll] is responsible for notifying the affected listeners. poll.handleSubmessageEvent(event); } + + /// In debug mode, controls whether outbox messages should be created when + /// [sendMessage] is called. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugOutboxEnable { + bool result = true; + assert(() { + result = _debugOutboxEnable; + return true; + }()); + return result; + } + static bool _debugOutboxEnable = true; + static set debugOutboxEnable(bool value) { + assert(() { + _debugOutboxEnable = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + _debugOutboxEnable = true; + } +} + +/// The duration an outbox message stays hidden to the user. +/// +/// See [OutboxMessageState.waiting]. +const kLocalEchoDebounceDuration = Duration(milliseconds: 500); // TODO(#1441) find the right value for this + +/// The duration before an outbox message can be restored for resending, since +/// its creation. +/// +/// See [OutboxMessageState.waitPeriodExpired]. +const kSendMessageOfferRestoreWaitPeriod = Duration(seconds: 10); // TODO(#1441) find the right value for this + +/// States of an [OutboxMessage] since its creation from a +/// [MessageStore.sendMessage] call and before its eventual deletion. +/// +/// ``` +/// Got an [ApiRequestException]. +/// ┌──────┬──────────┬─────────────► failed +/// (create) │ │ │ │ +/// └► hidden waiting waitPeriodExpired ──┴──────────────► (delete) +/// │ ▲ │ ▲ User restores +/// └──────┘ └──────┘ the draft. +/// Debounce [sendMessage] request +/// timed out. not finished when +/// wait period timed out. +/// +/// Event received. +/// (any state) ─────────────────► (delete) +/// ``` +/// +/// During its lifecycle, it is guaranteed that the outbox message is deleted +/// as soon a message event with a matching [MessageEvent.localMessageId] +/// arrives. +enum OutboxMessageState { + /// The [sendMessage] HTTP request has started but the resulting + /// [MessageEvent] hasn't arrived, and nor has the request failed. In this + /// state, the outbox message is hidden to the user. + /// + /// This is the initial state when an [OutboxMessage] is created. + hidden, + + /// The [sendMessage] HTTP request has started but hasn't finished, and the + /// outbox message is shown to the user. + /// + /// This state can be reached after staying in [hidden] for + /// [kLocalEchoDebounceDuration]. + waiting, + + /// The [sendMessage] HTTP request did not finish in time and the user is + /// invited to retry it. + /// + /// This state can be reached when the request has not finished + /// [kSendMessageOfferRestoreWaitPeriod] since the outbox message's creation. + waitPeriodExpired, + + /// The message could not be delivered, and the user is invited to retry it. + /// + /// This state can be reached when we got an [ApiRequestException] from the + /// [sendMessage] HTTP request. + failed, +} + +/// An outstanding request to send a message, aka an outbox-message. +/// +/// This will be shown in the UI in the message list, as a placeholder +/// for the actual [Message] the request is anticipated to produce. +/// +/// A request remains "outstanding" even after the [sendMessage] HTTP request +/// completes, whether with success or failure. +/// The outbox-message persists until either the corresponding [MessageEvent] +/// arrives to replace it, or the user discards it (perhaps to try again). +/// For details, see the state diagram at [OutboxMessageState], +/// and [MessageStore.takeOutboxMessage]. +sealed class OutboxMessage extends MessageBase { + OutboxMessage({ + required this.localMessageId, + required int selfUserId, + required super.timestamp, + required this.contentMarkdown, + }) : _state = OutboxMessageState.hidden, + super(senderId: selfUserId); + + // TODO(dart): This has to be a plain static method, because factories/constructors + // do not support type parameters: https://github.com/dart-lang/language/issues/647 + static OutboxMessage fromConversation(Conversation conversation, { + required int localMessageId, + required int selfUserId, + required int timestamp, + required String contentMarkdown, + }) { + return switch (conversation) { + StreamConversation() => StreamOutboxMessage._( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: timestamp, + conversation: conversation, + contentMarkdown: contentMarkdown), + DmConversation() => DmOutboxMessage._( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: timestamp, + conversation: conversation, + contentMarkdown: contentMarkdown), + }; + } + + /// As in [MessageEvent.localMessageId]. + /// + /// This uniquely identifies this outbox message's corresponding message object + /// in events from the same event queue. + /// + /// See also: + /// * [MessageStoreImpl.sendMessage], where this ID is assigned. + final int localMessageId; + + @override + int? get id => null; + + final String contentMarkdown; + + OutboxMessageState get state => _state; + OutboxMessageState _state; + + /// Whether the [OutboxMessage] is hidden to [MessageListView] or not. + bool get hidden => state == OutboxMessageState.hidden; +} + +class StreamOutboxMessage extends OutboxMessage { + StreamOutboxMessage._({ + required super.localMessageId, + required super.selfUserId, + required super.timestamp, + required this.conversation, + required super.contentMarkdown, + }); + + @override + final StreamConversation conversation; +} + +class DmOutboxMessage extends OutboxMessage { + DmOutboxMessage._({ + required super.localMessageId, + required super.selfUserId, + required super.timestamp, + required this.conversation, + required super.contentMarkdown, + }) : assert(conversation.allRecipientIds.contains(selfUserId)); + + @override + final DmConversation conversation; +} + +/// Manages the outbox messages portion of [MessageStore]. +mixin _OutboxMessageStore on PerAccountStoreBase { + late final UnmodifiableMapView outboxMessages = + UnmodifiableMapView(_outboxMessages); + final Map _outboxMessages = {}; + + /// A map of timers to show outbox messages after a delay, + /// indexed by [OutboxMessage.localMessageId]. + /// + /// If the send message request fails within the time limit, + /// the outbox message's timer gets removed and cancelled. + final Map _outboxMessageDebounceTimers = {}; + + /// A map of timers to update outbox messages state to + /// [OutboxMessageState.waitPeriodExpired] if the [sendMessage] + /// request did not complete in time, + /// indexed by [OutboxMessage.localMessageId]. + /// + /// If the send message request completes within the time limit, + /// the outbox message's timer gets removed and cancelled. + final Map _outboxMessageWaitPeriodTimers = {}; + + /// A fresh ID to use for [OutboxMessage.localMessageId], + /// unique within this instance. + int _nextLocalMessageId = 1; + + /// As in [MessageStoreImpl._messageListViews]. + Set get _messageListViews; + + /// As in [MessageStoreImpl._disposed]. + bool get _disposed; + + /// Update the state of the [OutboxMessage] with the given [localMessageId], + /// and notify listeners if necessary. + /// + /// The outbox message with [localMessageId] must exist. + void _updateOutboxMessage(int localMessageId, { + required OutboxMessageState newState, + }) { + assert(!_disposed); + final outboxMessage = outboxMessages[localMessageId]; + if (outboxMessage == null) { + throw StateError( + 'Removing unknown outbox message with localMessageId: $localMessageId'); + } + final oldState = outboxMessage.state; + // See [OutboxMessageState] for valid state transitions. + final isStateTransitionValid = switch (newState) { + OutboxMessageState.hidden => false, + OutboxMessageState.waiting => + oldState == OutboxMessageState.hidden, + OutboxMessageState.waitPeriodExpired => + oldState == OutboxMessageState.waiting, + OutboxMessageState.failed => + oldState == OutboxMessageState.hidden + || oldState == OutboxMessageState.waiting + || oldState == OutboxMessageState.waitPeriodExpired, + }; + if (!isStateTransitionValid) { + throw StateError('Unexpected state transition: $oldState -> $newState'); + } + + outboxMessage._state = newState; + for (final view in _messageListViews) { + if (oldState == OutboxMessageState.hidden) { + view.addOutboxMessage(outboxMessage); + } else { + view.notifyListenersIfOutboxMessagePresent(localMessageId); + } + } + } + + /// Send a message and create an entry of [OutboxMessage]. + Future _outboxSendMessage({ + required MessageDestination destination, + required String content, + required String? realmEmptyTopicDisplayName, + }) async { + assert(!_disposed); + final localMessageId = _nextLocalMessageId++; + assert(!outboxMessages.containsKey(localMessageId)); + + final conversation = switch (destination) { + StreamDestination(:final streamId, :final topic) => + StreamConversation( + streamId, + _processTopicLikeServer( + topic, realmEmptyTopicDisplayName: realmEmptyTopicDisplayName), + displayRecipient: null), + DmDestination(:final userIds) => DmConversation(allRecipientIds: userIds), + }; + + _outboxMessages[localMessageId] = OutboxMessage.fromConversation( + conversation, + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: ZulipBinding.instance.utcNow().millisecondsSinceEpoch ~/ 1000, + contentMarkdown: content); + + _outboxMessageDebounceTimers[localMessageId] = Timer( + kLocalEchoDebounceDuration, + () => _handleOutboxDebounce(localMessageId)); + + _outboxMessageWaitPeriodTimers[localMessageId] = Timer( + kSendMessageOfferRestoreWaitPeriod, + () => _handleOutboxWaitPeriodExpired(localMessageId)); + + try { + await _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true, + queueId: queueId, + localId: localMessageId.toString()); + } catch (e) { + if (_disposed) return; + if (!_outboxMessages.containsKey(localMessageId)) { + // The message event already arrived; the failure is probably due to + // networking issues. Don't rethrow; the send succeeded + // (we got the event) so we don't want to show an error dialog. + return; + } + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.failed); + rethrow; + } + if (_disposed) return; + if (!_outboxMessages.containsKey(localMessageId)) { + // The message event already arrived; nothing to do. + return; + } + // The send request succeeded, so the message was definitely sent. + // Cancel the timer that would have had us start presuming that the + // send might have failed. + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + } + + TopicName _processTopicLikeServer(TopicName topic, { + required String? realmEmptyTopicDisplayName, + }) { + return topic.processLikeServer( + // Processing this just once on creating the outbox message + // allows an uncommon bug, because either of these values can change. + // During the outbox message's life, a topic processed from + // "(no topic)" could become stale/wrong when zulipFeatureLevel + // changes; a topic processed from "general chat" could become + // stale/wrong when realmEmptyTopicDisplayName changes. + // + // Shrug. The same effect is caused by an unavoidable race: + // an admin could change the name of "general chat" + // (i.e. the value of realmEmptyTopicDisplayName) + // concurrently with the user making the send request, + // so that the setting in effect by the time the request arrives + // is different from the setting the client last heard about. + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName); + } + + void _handleOutboxDebounce(int localMessageId) { + assert(!_disposed); + assert(outboxMessages.containsKey(localMessageId), + 'The timer should have been canceled when the outbox message was removed.'); + _outboxMessageDebounceTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); + } + + void _handleOutboxWaitPeriodExpired(int localMessageId) { + assert(!_disposed); + assert(outboxMessages.containsKey(localMessageId), + 'The timer should have been canceled when the outbox message was removed.'); + assert(!_outboxMessageDebounceTimers.containsKey(localMessageId), + 'The debounce timer should have been removed before the wait period timer expires.'); + _outboxMessageWaitPeriodTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waitPeriodExpired); + } + + OutboxMessage takeOutboxMessage(int localMessageId) { + assert(!_disposed); + final removed = _outboxMessages.remove(localMessageId); + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + if (removed == null) { + throw StateError( + 'Removing unknown outbox message with localMessageId: $localMessageId'); + } + if (removed.state != OutboxMessageState.failed + && removed.state != OutboxMessageState.waitPeriodExpired + ) { + throw StateError('Unexpected state when restoring draft: ${removed.state}'); + } + for (final view in _messageListViews) { + view.removeOutboxMessage(removed); + } + return removed; + } + + void _handleMessageEventOutbox(MessageEvent event) { + if (event.localMessageId != null) { + final localMessageId = int.parse(event.localMessageId!, radix: 10); + // The outbox message can be missing if the user removes it (to be + // implemented in #1441) before the event arrives. + // Nothing to do in that case. + _outboxMessages.remove(localMessageId); + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + } + } + + /// Cancel [_OutboxMessageStore]'s timers. + void _disposeOutboxMessages() { + assert(!_disposed); + for (final timer in _outboxMessageDebounceTimers.values) { + timer.cancel(); + } + for (final timer in _outboxMessageWaitPeriodTimers.values) { + timer.cancel(); + } + } } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index f2a45b78aa..4da9ebd3cc 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -10,6 +10,7 @@ import '../api/route/messages.dart'; import 'algorithms.dart'; import 'channel.dart'; import 'content.dart'; +import 'message.dart'; import 'narrow.dart'; import 'store.dart'; @@ -616,6 +617,20 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Add [outboxMessage] if it belongs to the view. + void addOutboxMessage(OutboxMessage outboxMessage) { + // TODO(#1441) implement this + } + + /// Remove the [outboxMessage] from the view. + /// + /// This is a no-op if the message is not found. + /// + /// This should only be called from [MessageStore.takeOutboxMessage]. + void removeOutboxMessage(OutboxMessage outboxMessage) { + // TODO(#1441) implement this + } + void handleUserTopicEvent(UserTopicEvent event) { switch (_canAffectVisibility(event)) { case VisibilityEffect.none: @@ -777,6 +792,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Notify listeners if the given outbox message is present in this view. + void notifyListenersIfOutboxMessagePresent(int localMessageId) { + // TODO(#1441) implement this + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// This will redo from scratch any computations we can, such as parsing diff --git a/lib/model/store.dart b/lib/model/store.dart index 240e3ab4e4..18a09e32ce 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -501,7 +501,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds), ), channels: channels, - messages: MessageStoreImpl(core: core), + messages: MessageStoreImpl(core: core, + realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName), unreads: Unreads( initial: initialSnapshot.unreadMsgs, core: core, @@ -745,6 +746,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor @override Map get messages => _messages.messages; @override + Map get outboxMessages => _messages.outboxMessages; + @override void registerMessageList(MessageListView view) => _messages.registerMessageList(view); @override @@ -756,6 +759,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor return _messages.sendMessage(destination: destination, content: content); } @override + OutboxMessage takeOutboxMessage(int localMessageId) => + _messages.takeOutboxMessage(localMessageId); + @override void reconcileMessages(List messages) { _messages.reconcileMessages(messages); // TODO(#649) notify [unreads] of the just-fetched messages diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index b90238ae35..3ae106afcc 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -37,6 +37,7 @@ extension TopicNameChecks on Subject { } extension StreamConversationChecks on Subject { + Subject get topic => has((x) => x.topic, 'topic'); Subject get displayRecipient => has((x) => x.displayRecipient, 'displayRecipient'); } diff --git a/test/example_data.dart b/test/example_data.dart index d803196269..93869df37a 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -695,8 +695,8 @@ UserTopicEvent userTopicEvent( ); } -MessageEvent messageEvent(Message message) => - MessageEvent(id: 0, message: message, localMessageId: null); +MessageEvent messageEvent(Message message, {int? localMessageId}) => + MessageEvent(id: 0, message: message, localMessageId: localMessageId?.toString()); DeleteMessageEvent deleteMessageEvent(List messages) { assert(messages.isNotEmpty); diff --git a/test/fake_async_checks.dart b/test/fake_async_checks.dart new file mode 100644 index 0000000000..51c653123a --- /dev/null +++ b/test/fake_async_checks.dart @@ -0,0 +1,6 @@ +import 'package:checks/checks.dart'; +import 'package:fake_async/fake_async.dart'; + +extension FakeTimerChecks on Subject { + Subject get duration => has((t) => t.duration, 'duration'); +} diff --git a/test/model/message_checks.dart b/test/model/message_checks.dart new file mode 100644 index 0000000000..b56cd89a79 --- /dev/null +++ b/test/model/message_checks.dart @@ -0,0 +1,9 @@ +import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/message.dart'; + +extension OutboxMessageChecks on Subject> { + Subject get localMessageId => has((x) => x.localMessageId, 'localMessageId'); + Subject get state => has((x) => x.state, 'state'); + Subject get hidden => has((x) => x.hidden, 'hidden'); +} diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 1809f0888b..4f8183d6d4 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1,14 +1,17 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; import 'package:crypto/crypto.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -18,12 +21,17 @@ import '../api/model/model_checks.dart'; import '../api/model/submessage_checks.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; +import '../fake_async_checks.dart'; import '../stdlib_checks.dart'; +import 'binding.dart'; +import 'message_checks.dart'; import 'message_list_test.dart'; import 'store_checks.dart'; import 'test_store.dart'; void main() { + TestZulipBinding.ensureInitialized(); + // These "late" variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; @@ -42,10 +50,16 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [store] and the rest of the test state. - Future prepare({Narrow narrow = const CombinedFeedNarrow()}) async { - final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); + Future prepare({ + Narrow narrow = const CombinedFeedNarrow(), + ZulipStream? stream, + int? zulipFeatureLevel, + }) async { + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); - store = eg.store(); + final selfAccount = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + store = eg.store(account: selfAccount, + initialSnapshot: eg.initialSnapshot(zulipFeatureLevel: zulipFeatureLevel)); await store.addStream(stream); await store.addSubscription(subscription); connection = store.connection as FakeApiConnection; @@ -54,8 +68,12 @@ void main() { ..addListener(() { notifiedCount++; }); + addTearDown(messageList.dispose); check(messageList).fetched.isFalse(); checkNotNotified(); + + // This cleans up possibly pending timers from [MessageStoreImpl]. + addTearDown(store.dispose); } /// Perform the initial message fetch for [messageList]. @@ -76,6 +94,314 @@ void main() { checkNotified(count: messageList.fetched ? messages.length : 0); } + test('dispose cancels pending timers', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final store = eg.store(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + (store.connection as FakeApiConnection).prepare( + json: SendMessageResult(id: 1).toJson(), + delay: const Duration(seconds: 1)); + unawaited(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('topic')), + content: 'content')); + check(async.pendingTimers).deepEquals(>[ + (it) => it.isA().duration.equals(kLocalEchoDebounceDuration), + (it) => it.isA().duration.equals(kSendMessageOfferRestoreWaitPeriod), + (it) => it.isA().duration.equals(const Duration(seconds: 1)), + ]); + + store.dispose(); + check(async.pendingTimers).single.duration.equals(const Duration(seconds: 1)); + })); + + group('sendMessage', () { + final stream = eg.stream(); + final streamDestination = StreamDestination(stream.streamId, eg.t('some topic')); + late StreamMessage message; + + test('outbox messages get unique localMessageId', () async { + await prepare(stream: stream); + await prepareMessages([]); + + for (int i = 0; i < 10; i++) { + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage(destination: streamDestination, content: 'content'); + } + // [store.outboxMessages] has the same number of keys (localMessageId) + // as the number of sent messages, which are guaranteed to be distinct. + check(store.outboxMessages).keys.length.equals(10); + }); + + Subject checkState() => + check(store.outboxMessages).values.single.state; + + Future prepareOutboxMessage({ + MessageDestination? destination, + int? zulipFeatureLevel, + }) async { + message = eg.streamMessage(stream: stream); + await prepare(stream: stream, zulipFeatureLevel: zulipFeatureLevel); + await prepareMessages([eg.streamMessage(stream: stream)]); + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: destination ?? streamDestination, content: 'content'); + } + + late Future outboxMessageFailFuture; + Future prepareOutboxMessageToFailAfterDelay(Duration delay) async { + message = eg.streamMessage(stream: stream); + await prepare(stream: stream); + await prepareMessages([eg.streamMessage(stream: stream)]); + connection.prepare(httpException: SocketException('failed'), delay: delay); + outboxMessageFailFuture = store.sendMessage( + destination: streamDestination, content: 'content'); + } + + Future receiveMessage([Message? messageReceived]) async { + await store.handleEvent(eg.messageEvent(messageReceived ?? message, + localMessageId: store.outboxMessages.keys.single)); + } + + test('smoke DM: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(destination: DmDestination( + userIds: [eg.selfUser.userId, eg.otherUser.userId])); + checkState().equals(OutboxMessageState.hidden); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + await receiveMessage(eg.dmMessage(from: eg.selfUser, to: [eg.otherUser])); + check(store.outboxMessages).isEmpty(); + })); + + test('smoke stream message: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(destination: StreamDestination( + stream.streamId, eg.t('foo'))); + checkState().equals(OutboxMessageState.hidden); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + await receiveMessage(eg.streamMessage(stream: stream, topic: 'foo')); + check(store.outboxMessages).isEmpty(); + })); + + test('hidden -> waiting and never transition to waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + checkState().equals(OutboxMessageState.hidden); + + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // the send request was initiated. + async.elapse( + kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); + async.flushTimers(); + // The outbox message should stay in the waiting state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.waiting); + })); + + test('waiting -> waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + async.elapse(kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + await check(outboxMessageFailFuture).throws(); + })); + + group('… -> failed', () { + test('hidden -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + checkState().equals(OutboxMessageState.hidden); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // the send request was initiated. + async.elapse(kSendMessageOfferRestoreWaitPeriod); + async.flushTimers(); + // The outbox message should stay in the failed state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.failed); + })); + + test('waiting -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kLocalEchoDebounceDuration + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + })); + + test('waitPeriodExpired -> failed', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + })); + }); + + group('… -> (delete)', () { + test('hidden -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + checkState().equals(OutboxMessageState.hidden); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + })); + + test('hidden -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay(const Duration(seconds: 1)); + checkState().equals(OutboxMessageState.hidden); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + async.elapse(const Duration(seconds: 1)); + })); + + test('waiting -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessage(); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + })); + + test('waiting -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay( + kLocalEchoDebounceDuration + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + checkState().equals(OutboxMessageState.waiting); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + })); + + test('waitPeriodExpired -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + // Received the message event while the message is being sent. + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + + // Complete the send request. There should be no error despite + // the send request failure, because the outbox message is not + // in the store any more. + await check(outboxMessageFailFuture).completes(); + })); + + test('waitPeriodExpired -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the outbox message to be taken (by the user, presumably). + await prepareOutboxMessageToFailAfterDelay( + kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + store.takeOutboxMessage(store.outboxMessages.keys.single); + check(store.outboxMessages).isEmpty(); + })); + + test('failed -> (delete) because event received', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + + await receiveMessage(); + check(store.outboxMessages).isEmpty(); + })); + + test('failed -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { + await prepareOutboxMessageToFailAfterDelay(Duration.zero); + await check(outboxMessageFailFuture).throws(); + checkState().equals(OutboxMessageState.failed); + + store.takeOutboxMessage(store.outboxMessages.keys.single); + check(store.outboxMessages).isEmpty(); + })); + }); + + test('when sending to "(no topic)", process topic like the server does when creating outbox message', () => awaitFakeAsync((async) async { + await prepareOutboxMessage( + destination: StreamDestination(stream.streamId, TopicName('(no topic)')), + zulipFeatureLevel: 370); + async.elapse(kLocalEchoDebounceDuration); + check(store.outboxMessages).values.single + .conversation.isA().topic.equals(eg.t('')); + })); + + test('legacy: when sending to "(no topic)", process topic like the server does when creating outbox message', () => awaitFakeAsync((async) async { + await prepareOutboxMessage( + destination: StreamDestination(stream.streamId, TopicName('(no topic)')), + zulipFeatureLevel: 369); + async.elapse(kLocalEchoDebounceDuration); + check(store.outboxMessages).values.single + .conversation.isA().topic.equals(eg.t('(no topic)')); + })); + + test('set timestamp to now when creating outbox messages', () => awaitFakeAsync( + initialTime: eg.timeInPast, + (async) async { + await prepareOutboxMessage(); + check(store.outboxMessages).values.single + .timestamp.equals(eg.utcTimestamp(eg.timeInPast)); + }, + )); + }); + + test('takeOutboxMessage', () async { + final stream = eg.stream(); + await prepare(stream: stream); + await prepareMessages([]); + + for (int i = 0; i < 10; i++) { + connection.prepare(apiException: eg.apiBadRequest()); + await check(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('topic')), + content: 'content')).throws(); + } + + final localMessageIds = store.outboxMessages.keys.toList(); + store.takeOutboxMessage(localMessageIds.removeAt(5)); + check(store.outboxMessages).keys.deepEquals(localMessageIds); + }); + group('reconcileMessages', () { test('from empty', () async { await prepare(); diff --git a/test/model/narrow_test.dart b/test/model/narrow_test.dart index 06c82ed117..9d68873670 100644 --- a/test/model/narrow_test.dart +++ b/test/model/narrow_test.dart @@ -2,38 +2,37 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import '../example_data.dart' as eg; import 'narrow_checks.dart'; -/// A [MessageBase] subclass for testing. -// TODO(#1441): switch to outbox-messages instead -sealed class _TestMessage extends MessageBase { - @override - final int? id = null; - - _TestMessage() : super(senderId: eg.selfUser.userId, timestamp: 123456789); -} - -class _TestStreamMessage extends _TestMessage { - @override - final StreamConversation conversation; - - _TestStreamMessage({required ZulipStream stream, required String topic}) - : conversation = StreamConversation( - stream.streamId, TopicName(topic), displayRecipient: null); -} - -class _TestDmMessage extends _TestMessage { - @override - final DmConversation conversation; - - _TestDmMessage({required List allRecipientIds}) - : conversation = DmConversation(allRecipientIds: allRecipientIds); -} - void main() { + int nextLocalMessageId = 1; + + StreamOutboxMessage streamOutboxMessage({ + required ZulipStream stream, + required String topic, + }) { + return OutboxMessage.fromConversation( + StreamConversation( + stream.streamId, TopicName(topic), displayRecipient: null), + localMessageId: nextLocalMessageId++, + selfUserId: eg.selfUser.userId, + timestamp: 123456789, + contentMarkdown: 'content') as StreamOutboxMessage; + } + + DmOutboxMessage dmOutboxMessage({required List allRecipientIds}) { + return OutboxMessage.fromConversation( + DmConversation(allRecipientIds: allRecipientIds), + localMessageId: nextLocalMessageId++, + selfUserId: allRecipientIds[0], + timestamp: 123456789, + contentMarkdown: 'content') as DmOutboxMessage; + } + group('SendableNarrow', () { test('ofMessage: stream message', () { final message = eg.streamMessage(); @@ -61,11 +60,11 @@ void main() { eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1]))).isFalse(); + dmOutboxMessage(allRecipientIds: [1]))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: otherStream, topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic'))).isTrue(); + streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); }); }); @@ -91,13 +90,13 @@ void main() { eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1]))).isFalse(); + dmOutboxMessage(allRecipientIds: [1]))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: otherStream, topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic2'))).isFalse(); + streamOutboxMessage(stream: stream, topic: 'topic2'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic'))).isTrue(); + streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); }); }); @@ -223,13 +222,13 @@ void main() { final narrow = DmNarrow(allRecipientIds: [1, 2], selfUserId: 2); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [2]))).isFalse(); + dmOutboxMessage(allRecipientIds: [2]))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [2, 3]))).isFalse(); + dmOutboxMessage(allRecipientIds: [2, 3]))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1, 2]))).isTrue(); + dmOutboxMessage(allRecipientIds: [1, 2]))).isTrue(); }); }); @@ -245,9 +244,9 @@ void main() { eg.streamMessage(flags: [MessageFlag.wildcardMentioned]))).isTrue(); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); + dmOutboxMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); }); }); @@ -261,9 +260,9 @@ void main() { eg.streamMessage(flags:[MessageFlag.starred]))).isTrue(); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); + dmOutboxMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); }); }); } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index eba1505747..0b303b53e2 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -569,7 +569,8 @@ void main() { group('PerAccountStore.sendMessage', () { test('smoke', () async { - final store = eg.store(); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + queueId: 'fb67bf8a-c031-47cc-84cf-ed80accacda8')); final connection = store.connection as FakeApiConnection; final stream = eg.stream(); connection.prepare(json: SendMessageResult(id: 12345).toJson()); @@ -585,6 +586,8 @@ void main() { 'topic': 'world', 'content': 'hello', 'read_by_sender': 'true', + 'queue_id': 'fb67bf8a-c031-47cc-84cf-ed80accacda8', + 'local_id': store.outboxMessages.keys.single.toString(), }); }); }); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 679f4de190..11467cea7d 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -15,6 +15,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; @@ -295,6 +296,8 @@ void main() { Future prepareWithContent(WidgetTester tester, String content) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -332,6 +335,8 @@ void main() { Future prepareWithTopic(WidgetTester tester, String topic) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -723,6 +728,8 @@ void main() { }); testWidgets('hitting send button sends a "typing stopped" notice', (tester) async { + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -829,6 +836,8 @@ void main() { }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await prepareComposeBox(tester, narrow: eg.topicNarrow(123, 'some topic'), @@ -883,6 +892,8 @@ void main() { }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); channel = eg.stream(); final narrow = ChannelNarrow(channel.streamId); @@ -1419,6 +1430,8 @@ void main() { int msgIdInNarrow(Narrow narrow) => msgInNarrow(narrow).id; Future prepareEditMessage(WidgetTester tester, {required Narrow narrow}) async { + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index df05b4f0cc..88c4cdb64b 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -943,7 +943,8 @@ void main() { connection.prepare(json: SendMessageResult(id: 1).toJson()); await tester.tap(find.byIcon(ZulipIcons.send)); - await tester.pump(); + await tester.pump(Duration.zero); + final localMessageId = store.outboxMessages.keys.single; check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') @@ -952,8 +953,12 @@ void main() { 'to': '${otherChannel.streamId}', 'topic': 'new topic', 'content': 'Some text', - 'read_by_sender': 'true'}); - await tester.pumpAndSettle(); + 'read_by_sender': 'true', + 'queue_id': store.queueId, + 'local_id': localMessageId.toString()}); + // Remove the outbox message and its timers created when sending message. + await store.handleEvent( + eg.messageEvent(message, localMessageId: localMessageId)); }); testWidgets('Move to narrow with existing messages', (tester) async { From 61e343b9d8ca35b207a53e14789fb3c8e92267ca Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 21 May 2025 16:49:28 -0400 Subject: [PATCH 082/290] message: Avoid double-sends after send-message request succeeds This implements the waitPeriodExpired -> waiting state transition. GitHub discussion: https://github.com/zulip/zulip-flutter/pull/1472#discussion_r2099285217 --- lib/model/message.dart | 28 ++++++++++++++++++++-------- test/model/message_test.dart | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 3fffdfc4a4..719d0704f6 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -530,12 +530,14 @@ const kSendMessageOfferRestoreWaitPeriod = Duration(seconds: 10); // TODO(#1441 /// [MessageStore.sendMessage] call and before its eventual deletion. /// /// ``` -/// Got an [ApiRequestException]. -/// ┌──────┬──────────┬─────────────► failed -/// (create) │ │ │ │ -/// └► hidden waiting waitPeriodExpired ──┴──────────────► (delete) -/// │ ▲ │ ▲ User restores -/// └──────┘ └──────┘ the draft. +/// Got an [ApiRequestException]. +/// ┌──────┬────────────────────────────┬──────────► failed +/// │ │ │ │ +/// │ │ [sendMessage] │ │ +/// (create) │ │ request succeeds. │ │ +/// └► hidden waiting ◄─────────────── waitPeriodExpired ──┴─────► (delete) +/// │ ▲ │ ▲ User restores +/// └──────┘ └─────────────────────┘ the draft. /// Debounce [sendMessage] request /// timed out. not finished when /// wait period timed out. @@ -559,7 +561,8 @@ enum OutboxMessageState { /// outbox message is shown to the user. /// /// This state can be reached after staying in [hidden] for - /// [kLocalEchoDebounceDuration]. + /// [kLocalEchoDebounceDuration], or when the request succeeds after the + /// outbox message reaches [OutboxMessageState.waitPeriodExpired]. waiting, /// The [sendMessage] HTTP request did not finish in time and the user is @@ -717,7 +720,8 @@ mixin _OutboxMessageStore on PerAccountStoreBase { final isStateTransitionValid = switch (newState) { OutboxMessageState.hidden => false, OutboxMessageState.waiting => - oldState == OutboxMessageState.hidden, + oldState == OutboxMessageState.hidden + || oldState == OutboxMessageState.waitPeriodExpired, OutboxMessageState.waitPeriodExpired => oldState == OutboxMessageState.waiting, OutboxMessageState.failed => @@ -803,6 +807,14 @@ mixin _OutboxMessageStore on PerAccountStoreBase { // Cancel the timer that would have had us start presuming that the // send might have failed. _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + if (_outboxMessages[localMessageId]!.state + == OutboxMessageState.waitPeriodExpired) { + // The user was offered to restore the message since the request did not + // complete for a while. Since the request was successful, we expect the + // message event to arrive eventually. Stop inviting the the user to + // retry, to avoid double-sends. + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); + } } TopicName _processTopicLikeServer(TopicName topic, { diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 4f8183d6d4..7dff077b1d 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -217,6 +217,33 @@ void main() { await check(outboxMessageFailFuture).throws(); })); + test('waiting -> waitPeriodExpired -> waiting and never return to waitPeriodExpired', () => awaitFakeAsync((async) async { + await prepare(stream: stream); + await prepareMessages([eg.streamMessage(stream: stream)]); + // Set up a [sendMessage] request that succeeds after enough delay, + // for the outbox message to reach the waitPeriodExpired state. + // TODO extract helper to add prepare an outbox message with a delayed + // successful [sendMessage] request if we have more tests like this + connection.prepare(json: SendMessageResult(id: 1).toJson(), + delay: kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + final future = store.sendMessage( + destination: streamDestination, content: 'content'); + async.elapse(kSendMessageOfferRestoreWaitPeriod); + checkState().equals(OutboxMessageState.waitPeriodExpired); + + // Wait till the [sendMessage] request succeeds. + await future; + checkState().equals(OutboxMessageState.waiting); + + // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after + // returning to the waiting state. + async.elapse(kSendMessageOfferRestoreWaitPeriod); + async.flushTimers(); + // The outbox message should stay in the waiting state; + // it should not transition to waitPeriodExpired. + checkState().equals(OutboxMessageState.waiting); + })); + group('… -> failed', () { test('hidden -> failed', () => awaitFakeAsync((async) async { await prepareOutboxMessageToFailAfterDelay(Duration.zero); From 36f0cb71823c602675ae54008e76bc60e95a6a7e Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 29 Apr 2025 21:17:34 -0400 Subject: [PATCH 083/290] theme [nfc]: Move bgMessageRegular to DesignVariables --- lib/widgets/content.dart | 3 ++- lib/widgets/message_list.dart | 14 +++----------- lib/widgets/theme.dart | 7 +++++++ test/widgets/message_list_test.dart | 7 ++++--- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 40b510305d..62801ab867 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -26,6 +26,7 @@ import 'poll.dart'; import 'scrolling.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; /// A central place for styles for Zulip content (rendered Zulip Markdown). /// @@ -988,7 +989,7 @@ class WebsitePreview extends StatelessWidget { // TODO(#488) use different color for non-message contexts // TODO(#647) use different color for highlighted messages // TODO(#681) use different color for DM messages - color: MessageListTheme.of(context).bgMessageRegular, + color: DesignVariables.of(context).bgMessageRegular, child: ClipRect( child: ConstrainedBox( constraints: BoxConstraints(maxHeight: 80), diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index dcd063a62a..44ae56fb1e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -28,7 +28,6 @@ import 'theme.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { static final light = MessageListTheme._( - bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(), dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), labelTime: const HSLColor.fromAHSL(0.49, 0, 0, 0).toColor(), senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.08, 0.65).toColor(), @@ -46,7 +45,6 @@ class MessageListTheme extends ThemeExtension { ); static final dark = MessageListTheme._( - bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(), dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), labelTime: const HSLColor.fromAHSL(0.5, 0, 0, 1).toColor(), senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.05, 0.5).toColor(), @@ -63,7 +61,6 @@ class MessageListTheme extends ThemeExtension { ); MessageListTheme._({ - required this.bgMessageRegular, required this.dmRecipientHeaderBg, required this.labelTime, required this.senderBotIcon, @@ -82,7 +79,6 @@ class MessageListTheme extends ThemeExtension { return extension!; } - final Color bgMessageRegular; final Color dmRecipientHeaderBg; final Color labelTime; final Color senderBotIcon; @@ -92,7 +88,6 @@ class MessageListTheme extends ThemeExtension { @override MessageListTheme copyWith({ - Color? bgMessageRegular, Color? dmRecipientHeaderBg, Color? labelTime, Color? senderBotIcon, @@ -101,7 +96,6 @@ class MessageListTheme extends ThemeExtension { Color? unreadMarkerGap, }) { return MessageListTheme._( - bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, dmRecipientHeaderBg: dmRecipientHeaderBg ?? this.dmRecipientHeaderBg, labelTime: labelTime ?? this.labelTime, senderBotIcon: senderBotIcon ?? this.senderBotIcon, @@ -117,7 +111,6 @@ class MessageListTheme extends ThemeExtension { return this; } return MessageListTheme._( - bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!, dmRecipientHeaderBg: Color.lerp(dmRecipientHeaderBg, other.dmRecipientHeaderBg, t)!, labelTime: Color.lerp(labelTime, other.labelTime, t)!, senderBotIcon: Color.lerp(senderBotIcon, other.senderBotIcon, t)!, @@ -981,13 +974,12 @@ class DateSeparator extends StatelessWidget { // to align with the vertically centered divider lines. const textBottomPadding = 2.0; - final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); final line = BorderSide(width: 0, color: designVariables.foreground); // TODO(#681) use different color for DM messages - return ColoredBox(color: messageListTheme.bgMessageRegular, + return ColoredBox(color: designVariables.bgMessageRegular, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), child: Row(children: [ @@ -1026,11 +1018,11 @@ class MessageItem extends StatelessWidget { @override Widget build(BuildContext context) { - final messageListTheme = MessageListTheme.of(context); + final designVariables = DesignVariables.of(context); final item = this.item; Widget child = ColoredBox( - color: messageListTheme.bgMessageRegular, + color: designVariables.bgMessageRegular, child: Column(children: [ switch (item) { MessageListMessageItem() => MessageWithPossibleSender(item: item), diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 276e308b2b..492f82c88a 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -137,6 +137,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), bgMenuButtonActive: Colors.black.withValues(alpha: 0.05), bgMenuButtonSelected: Colors.white, + bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(), bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2), @@ -197,6 +198,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), bgMenuButtonActive: Colors.black.withValues(alpha: 0.2), bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25), + bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(), bgTopBar: const Color(0xff242424), borderBar: const Color(0xffffffff).withValues(alpha: 0.1), borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), @@ -265,6 +267,7 @@ class DesignVariables extends ThemeExtension { required this.bgCounterUnread, required this.bgMenuButtonActive, required this.bgMenuButtonSelected, + required this.bgMessageRegular, required this.bgTopBar, required this.borderBar, required this.borderMenuButtonSelected, @@ -334,6 +337,7 @@ class DesignVariables extends ThemeExtension { final Color bgCounterUnread; final Color bgMenuButtonActive; final Color bgMenuButtonSelected; + final Color bgMessageRegular; final Color bgTopBar; final Color borderBar; final Color borderMenuButtonSelected; @@ -398,6 +402,7 @@ class DesignVariables extends ThemeExtension { Color? bgCounterUnread, Color? bgMenuButtonActive, Color? bgMenuButtonSelected, + Color? bgMessageRegular, Color? bgTopBar, Color? borderBar, Color? borderMenuButtonSelected, @@ -457,6 +462,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, bgMenuButtonActive: bgMenuButtonActive ?? this.bgMenuButtonActive, bgMenuButtonSelected: bgMenuButtonSelected ?? this.bgMenuButtonSelected, + bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected, @@ -523,6 +529,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, bgMenuButtonActive: Color.lerp(bgMenuButtonActive, other.bgMenuButtonActive, t)!, bgMenuButtonSelected: Color.lerp(bgMenuButtonSelected, other.bgMenuButtonSelected, t)!, + bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!, bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!, diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 88c4cdb64b..9b614b735b 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -27,6 +27,7 @@ import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/theme.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -282,17 +283,17 @@ void main() { return widget.color; } - check(backgroundColor()).isSameColorAs(MessageListTheme.light.bgMessageRegular); + check(backgroundColor()).isSameColorAs(DesignVariables.light.bgMessageRegular); tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; await tester.pump(); await tester.pump(kThemeAnimationDuration * 0.4); - final expectedLerped = MessageListTheme.light.lerp(MessageListTheme.dark, 0.4); + final expectedLerped = DesignVariables.light.lerp(DesignVariables.dark, 0.4); check(backgroundColor()).isSameColorAs(expectedLerped.bgMessageRegular); await tester.pump(kThemeAnimationDuration * 0.6); - check(backgroundColor()).isSameColorAs(MessageListTheme.dark.bgMessageRegular); + check(backgroundColor()).isSameColorAs(DesignVariables.dark.bgMessageRegular); }); group('fetch initial batch of messages', () { From d68d5a7e2cd87d89588389cfda566846c84310a9 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 29 Apr 2025 21:16:09 -0400 Subject: [PATCH 084/290] topics: Add topic list page For the topic-list page app bar, we leave out the icon "chevron_down.svg" since it's related to a new design (#1039) we haven't implemented yet. This also why "TOPICS" is not aligned to the middle part of the app bar on the message-list page. We also leave out the new topic button and topic filtering, which are out-of-scope for #1158. The topic-list implementation is quite similar to parts of inbox page and message-list page. Therefore, we structure the code to make it easy to maintain in the future. Especially, this helps us (a) when we're changing one, apply the same change to the other, where appropriate, and (b) later reconcile the differences they do have and then refactor to unify them. Figma design: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6819-35869&m=dev The "TOPICS" icon on message-list page in a topic narrow is a UX change from the design. See CZO discussion: https://chat.zulip.org/#narrow/channel/48-mobile/topic/Flutter.20beta.3A.20missing.20topic.20list/near/2177505 --- assets/l10n/app_en.arb | 4 + lib/generated/l10n/zulip_localizations.dart | 6 + .../l10n/zulip_localizations_ar.dart | 3 + .../l10n/zulip_localizations_de.dart | 3 + .../l10n/zulip_localizations_en.dart | 3 + .../l10n/zulip_localizations_ja.dart | 3 + .../l10n/zulip_localizations_nb.dart | 3 + .../l10n/zulip_localizations_pl.dart | 3 + .../l10n/zulip_localizations_ru.dart | 3 + .../l10n/zulip_localizations_sk.dart | 3 + .../l10n/zulip_localizations_uk.dart | 3 + lib/widgets/message_list.dart | 55 ++- lib/widgets/topic_list.dart | 347 ++++++++++++++++++ test/widgets/message_list_test.dart | 40 ++ test/widgets/topic_list_test.dart | 330 +++++++++++++++++ 15 files changed, 801 insertions(+), 8 deletions(-) create mode 100644 lib/widgets/topic_list.dart create mode 100644 test/widgets/topic_list_test.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index d11bf43eda..1c0ec3d7e8 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -769,6 +769,10 @@ "@mainMenuMyProfile": { "description": "Label for main-menu button leading to the user's own profile." }, + "topicsButtonLabel": "TOPICS", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, "channelFeedButtonTooltip": "Channel feed", "@channelFeedButtonTooltip": { "description": "Tooltip for button to navigate to a given channel's feed" diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index ecb0eee16a..566ff617ad 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1151,6 +1151,12 @@ abstract class ZulipLocalizations { /// **'My profile'** String get mainMenuMyProfile; + /// Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'TOPICS'** + String get topicsButtonLabel; + /// Tooltip for button to navigate to a given channel's feed /// /// 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 98dd9a7af6..d1025b7151 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -629,6 +629,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 08d09bb3c4..eee1309e1b 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -629,6 +629,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 105162429b..fbdcea3935 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -629,6 +629,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 74a2d4bedb..a9a2e40dce 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -629,6 +629,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 02913278b8..afe3c9794a 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -629,6 +629,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get mainMenuMyProfile => 'My profile'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 26b4b7e306..7653f8d31d 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -638,6 +638,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get mainMenuMyProfile => 'Mój profil'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Strumień kanału'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 5d8899290d..6ea916c359 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -642,6 +642,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get mainMenuMyProfile => 'Мой профиль'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Лента канала'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 3ff534eca5..5430a885c4 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -631,6 +631,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get mainMenuMyProfile => 'Môj profil'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 94fee8825a..db213893db 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -641,6 +641,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get mainMenuMyProfile => 'Мій профіль'; + @override + String get topicsButtonLabel => 'TOPICS'; + @override String get channelFeedButtonTooltip => 'Стрічка каналу'; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 44ae56fb1e..0003ae5ed7 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -24,6 +24,7 @@ import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { @@ -220,14 +221,23 @@ class _MessageListPageState extends State implements MessageLis removeAppBarBottomBorder = true; } - List? actions; - if (narrow case TopicNarrow(:final streamId)) { - (actions ??= []).add(IconButton( - icon: const Icon(ZulipIcons.message_feed), - tooltip: zulipLocalizations.channelFeedButtonTooltip, - onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(streamId))))); + List actions = []; + switch (narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case DmNarrow(): + break; + case ChannelNarrow(:final streamId): + actions.add(_TopicListButton(streamId: streamId)); + case TopicNarrow(:final streamId): + actions.add(IconButton( + icon: const Icon(ZulipIcons.message_feed), + tooltip: zulipLocalizations.channelFeedButtonTooltip, + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(streamId))))); + actions.add(_TopicListButton(streamId: streamId)); } // Insert a PageRoot here, to provide a context that can be used for @@ -277,6 +287,35 @@ class _MessageListPageState extends State implements MessageLis } } +class _TopicListButton extends StatelessWidget { + const _TopicListButton({required this.streamId}); + + final int streamId; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + return GestureDetector( + onTap: () { + Navigator.of(context).push(TopicListPage.buildRoute( + context: context, streamId: streamId)); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsetsDirectional.fromSTEB(12, 8, 12, 8), + child: Center(child: Text(zulipLocalizations.topicsButtonLabel, + style: TextStyle( + color: designVariables.icon, + fontSize: 18, + height: 19 / 18, + // This is equivalent to css `all-small-caps`, see: + // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps + fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], + ).merge(weightVariableTextStyle(context, wght: 600)))))); + } +} + class MessageListAppBarTitle extends StatelessWidget { const MessageListAppBarTitle({ super.key, diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart new file mode 100644 index 0000000000..61e16df6ec --- /dev/null +++ b/lib/widgets/topic_list.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../api/route/channels.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/narrow.dart'; +import '../model/unreads.dart'; +import 'action_sheet.dart'; +import 'app_bar.dart'; +import 'color.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +class TopicListPage extends StatelessWidget { + const TopicListPage({super.key, required this.streamId}); + + final int streamId; + + static AccountRoute buildRoute({ + required BuildContext context, + required int streamId, + }) { + return MaterialAccountWidgetRoute( + context: context, + page: TopicListPage(streamId: streamId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final appBarBackgroundColor = colorSwatchFor( + context, store.subscriptions[streamId]).barBackground; + + return PageRoot(child: Scaffold( + appBar: ZulipAppBar( + backgroundColor: appBarBackgroundColor, + buildTitle: (willCenterTitle) => + _TopicListAppBarTitle(streamId: streamId, willCenterTitle: willCenterTitle), + actions: [ + IconButton( + icon: const Icon(ZulipIcons.message_feed), + tooltip: zulipLocalizations.channelFeedButtonTooltip, + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(streamId)))), + ]), + body: _TopicList(streamId: streamId))); + } +} + +// This is adapted from [MessageListAppBarTitle]. +class _TopicListAppBarTitle extends StatelessWidget { + const _TopicListAppBarTitle({ + required this.streamId, + required this.willCenterTitle, + }); + + final int streamId; + final bool willCenterTitle; + + Widget _buildStreamRow(BuildContext context) { + // TODO(#1039) implement a consistent app bar design here + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final stream = store.streams[streamId]; + final channelIconColor = colorSwatchFor(context, + store.subscriptions[streamId]).iconOnBarBackground; + + // A null [Icon.icon] makes a blank space. + final icon = stream != null ? iconDataForStream(stream) : null; + return Row( + mainAxisSize: MainAxisSize.min, + // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. + // For screenshots of some experiments, see: + // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746 + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding(padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), + child: Icon(size: 18, icon, color: channelIconColor)), + Flexible(child: Text( + stream?.name ?? zulipLocalizations.unknownChannelName, + style: TextStyle( + fontSize: 20, + height: 30 / 20, + color: designVariables.title, + ).merge(weightVariableTextStyle(context, wght: 600)))), + ]); + } + + @override + Widget build(BuildContext context) { + final alignment = willCenterTitle + ? Alignment.center + : AlignmentDirectional.centerStart; + return SizedBox( + width: double.infinity, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: () { + showChannelActionSheet(context, channelId: streamId); + }, + child: Align(alignment: alignment, + child: _buildStreamRow(context)))); + } +} + +class _TopicList extends StatefulWidget { + const _TopicList({required this.streamId}); + + final int streamId; + + @override + State<_TopicList> createState() => _TopicListState(); +} + +class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMixin { + Unreads? unreadsModel; + // TODO(#1499): store the results on [ChannelStore], and keep them + // up-to-date by handling events + List? lastFetchedTopics; + + @override + void onNewStore() { + unreadsModel?.removeListener(_modelChanged); + final store = PerAccountStoreWidget.of(context); + unreadsModel = store.unreads..addListener(_modelChanged); + _fetchTopics(); + } + + @override + void dispose() { + unreadsModel?.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in `unreadsModel`. + }); + } + + void _fetchTopics() async { + // Do nothing when the fetch fails; the topic-list will stay on + // the loading screen, until the user navigates away and back. + // TODO(design) show a nice error message on screen when this fails + final store = PerAccountStoreWidget.of(context); + final result = await getStreamTopics(store.connection, + streamId: widget.streamId, + allowEmptyTopicName: true); + if (!mounted) return; + setState(() { + lastFetchedTopics = result.topics; + }); + } + + @override + Widget build(BuildContext context) { + if (lastFetchedTopics == null) { + return const Center(child: CircularProgressIndicator()); + } + + // TODO(design) handle the rare case when `lastFetchedTopics` is empty + + // This is adapted from parts of the build method on [_InboxPageState]. + final topicItems = <_TopicItemData>[]; + for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) { + final unreadMessageIds = + unreadsModel!.streams[widget.streamId]?[topic] ?? []; + final countInTopic = unreadMessageIds.length; + final hasMention = unreadMessageIds.any((messageId) => + unreadsModel!.mentions.contains(messageId)); + topicItems.add(_TopicItemData( + topic: topic, + unreadCount: countInTopic, + hasMention: hasMention, + // `lastFetchedTopics.maxId` can become outdated when a new message + // arrives or when there are message moves, until we re-fetch. + // TODO(#1499): track changes to this + maxId: maxId, + )); + } + topicItems.sort((a, b) { + final aMaxId = a.maxId; + final bMaxId = b.maxId; + return bMaxId.compareTo(aMaxId); + }); + + return SafeArea( + // Don't pad the bottom here; we want the list content to do that. + bottom: false, + child: ListView.builder( + itemCount: topicItems.length, + itemBuilder: (context, index) => + _TopicItem(streamId: widget.streamId, data: topicItems[index])), + ); + } +} + +class _TopicItemData { + final TopicName topic; + final int unreadCount; + final bool hasMention; + final int maxId; + + const _TopicItemData({ + required this.topic, + required this.unreadCount, + required this.hasMention, + required this.maxId, + }); +} + +// This is adapted from `_TopicItem` in lib/widgets/inbox.dart. +// TODO(#1527) see if we can reuse this in redesign +class _TopicItem extends StatelessWidget { + const _TopicItem({required this.streamId, required this.data}); + + final int streamId; + final _TopicItemData data; + + @override + Widget build(BuildContext context) { + final _TopicItemData( + :topic, :unreadCount, :hasMention, :maxId) = data; + + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + final visibilityPolicy = store.topicVisibilityPolicy(streamId, topic); + final double opacity; + switch (visibilityPolicy) { + case UserTopicVisibilityPolicy.muted: + opacity = 0.5; + case UserTopicVisibilityPolicy.none: + case UserTopicVisibilityPolicy.unmuted: + case UserTopicVisibilityPolicy.followed: + opacity = 1; + case UserTopicVisibilityPolicy.unknown: + assert(false); + opacity = 1; + } + + final visibilityIcon = iconDataForTopicVisibilityPolicy(visibilityPolicy); + + return Material( + color: designVariables.bgMessageRegular, + child: InkWell( + onTap: () { + final narrow = TopicNarrow(streamId, topic); + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + onLongPress: () => showTopicActionSheet(context, + channelId: streamId, + topic: topic, + someMessageIdInTopic: maxId), + splashFactory: NoSplash.splashFactory, + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(6, 8, 12, 8), + child: Row( + spacing: 8, + // In the Figma design, the text and icons on the topic item row + // are aligned to the start on the cross axis + // (i.e., `align-items: flex-start`). The icons are padded down + // 2px relative to the start, to visibly sit on the baseline. + // To account for scaled text, we align everything on the row + // to [CrossAxisAlignment.center] instead ([Row]'s default), + // like we do for the topic items on the inbox page. + // TODO(#1528): align to baseline (and therefore to first line of + // topic name), but with adjustment for icons + // CZO discussion: + // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/topic.20list.20item.20alignment/near/2173252 + children: [ + // A null [Icon.icon] makes a blank space. + _IconMarker(icon: topic.isResolved ? ZulipIcons.check : null), + Expanded(child: Opacity( + opacity: opacity, + child: Text( + style: TextStyle( + fontSize: 17, + height: 20 / 17, + fontStyle: topic.displayName == null ? FontStyle.italic : null, + color: designVariables.textMessage, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), + Opacity(opacity: opacity, child: Row( + spacing: 4, + children: [ + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), + if (unreadCount > 0) _UnreadCountBadge(count: unreadCount), + ])), + ])))); + } +} + +class _IconMarker extends StatelessWidget { + const _IconMarker({required this.icon}); + + final IconData? icon; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final textScaler = MediaQuery.textScalerOf(context); + // Since we align the icons to [CrossAxisAlignment.center], the top padding + // from the Figma design is omitted. + return Icon(icon, + size: textScaler.clamp(maxScaleFactor: 1.5).scale(16), + color: designVariables.textMessage.withFadedAlpha(0.4)); + } +} + +// This is adapted from [UnreadCountBadge]. +// TODO(#1406) see if we can reuse this in redesign +// TODO(#1527) see if we can reuse this in redesign +class _UnreadCountBadge extends StatelessWidget { + const _UnreadCountBadge({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: designVariables.bgCounterUnread, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text(count.toString(), + style: TextStyle( + fontSize: 15, + height: 16 / 15, + color: designVariables.labelCounterUnread, + ).merge(weightVariableTextStyle(context, wght: 500))))); + } +} diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 9b614b735b..606a01f420 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -11,6 +11,7 @@ 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/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; @@ -28,6 +29,7 @@ import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/channel_colors.dart'; import 'package:zulip/widgets/theme.dart'; +import 'package:zulip/widgets/topic_list.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -229,6 +231,25 @@ void main() { .equals(ChannelNarrow(channel.streamId)); }); + testWidgets('has topic-list action for topic narrows', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await setupMessageListPage(tester, + narrow: eg.topicNarrow(channel.streamId, 'topic foo'), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic foo')]); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic foo'), + ]).toJson()); + await tester.tap(find.text('TOPICS')); + await tester.pump(); // tap the button + await tester.pump(Duration.zero); // wait for request + check(find.descendant( + of: find.byType(TopicListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); + testWidgets('show topic visibility policy for topic narrows', (tester) async { final channel = eg.stream(); const topic = 'topic'; @@ -244,6 +265,25 @@ void main() { of: find.byType(MessageListAppBarTitle), matching: find.byIcon(ZulipIcons.mute))).findsOne(); }); + + testWidgets('has topic-list action for channel narrows', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic foo')]); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic foo'), + ]).toJson()); + await tester.tap(find.text('TOPICS')); + await tester.pump(); // tap the button + await tester.pump(Duration.zero); // wait for request + check(find.descendant( + of: find.byType(TopicListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); }); group('presents message content appropriately', () { diff --git a/test/widgets/topic_list_test.dart b/test/widgets/topic_list_test.dart new file mode 100644 index 0000000000..cf76ff3917 --- /dev/null +++ b/test/widgets/topic_list_test.dart @@ -0,0 +1,330 @@ +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:http/http.dart' as http; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/channels.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/topic_list.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + + Future prepare(WidgetTester tester, { + ZulipStream? channel, + List? topics, + List userTopics = const [], + List? messages, + }) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + await store.addUser(eg.selfUser); + channel ??= eg.stream(); + await store.addStream(channel); + await store.addSubscription(eg.subscription(channel)); + for (final userTopic in userTopics) { + await store.addUserTopic( + channel, userTopic.topicName.apiName, userTopic.visibilityPolicy); + } + topics ??= [eg.getStreamTopicsEntry()]; + messages ??= [eg.streamMessage(stream: channel, topic: topics.first.name.apiName)]; + await store.addMessages(messages); + + connection.prepare(json: GetStreamTopicsResult(topics: topics).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + await tester.pump(Duration.zero); + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/users/me/${channel.streamId}/topics') + ..url.queryParameters.deepEquals({'allow_empty_topic_name': 'true'}); + } + + group('app bar', () { + testWidgets('unknown channel name', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final channel = eg.stream(); + + (store.connection as FakeApiConnection).prepare( + json: GetStreamTopicsResult(topics: []).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.widgetWithText(ZulipAppBar, '(unknown channel)')).findsOne(); + }); + + testWidgets('navigate to channel feed', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await prepare(tester, channel: channel); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [eg.streamMessage(stream: channel)]).toJson()); + await tester.tap(find.byIcon(ZulipIcons.message_feed)); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.descendant( + of: find.byType(MessageListPage), + matching: find.text('channel foo')), + ).findsOne(); + }); + + testWidgets('show channel action sheet', (tester) async { + final channel = eg.stream(name: 'channel foo'); + await prepare(tester, channel: channel, + messages: [eg.streamMessage(stream: channel)]); + + await tester.longPress(find.text('channel foo')); + await tester.pump(Duration(milliseconds: 100)); // bottom-sheet animation + check(find.text('Mark channel as read')).findsOne(); + }); + }); + + testWidgets('show loading indicator', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final channel = eg.stream(); + + (store.connection as FakeApiConnection).prepare( + json: GetStreamTopicsResult(topics: []).toJson(), + delay: Duration(seconds: 1), + ); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: channel.streamId))); + await tester.pump(); + check(find.byType(CircularProgressIndicator)).findsOne(); + + await tester.pump(Duration(seconds: 1)); + check(find.byType(CircularProgressIndicator)).findsNothing(); + }); + + testWidgets('fetch again when navigating away and back', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final connection = store.connection as FakeApiConnection; + final channel = eg.stream(); + + // Start from a message list page in a channel narrow. + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: []).toJson()); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: MessageListPage(initNarrow: ChannelNarrow(channel.streamId)))); + await tester.pump(); + + // Tap "TOPICS" button navigating to the topic-list page… + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: 'topic A')]).toJson()); + await tester.tap(find.text('TOPICS')); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.text('topic A')).findsOne(); + + // … go back to the message list page… + await tester.pageBack(); + await tester.pump(); + + // … then back to the topic-list page, expecting to fetch again. + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: 'topic B')]).toJson()); + await tester.tap(find.text('TOPICS')); + await tester.pump(); + await tester.pump(Duration.zero); + check(find.text('topic A')).findsNothing(); + check(find.text('topic B')).findsOne(); + }); + + Finder topicItemFinder = find.descendant( + of: find.byType(ListView), + matching: find.byType(Material)); + + Finder findInTopicItemAt(int index, Finder finder) => find.descendant( + of: topicItemFinder.at(index), + matching: finder); + + testWidgets('show topic action sheet', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic foo')]); + await tester.longPress(topicItemFinder); + await tester.pump(Duration(milliseconds: 150)); // bottom-sheet animation + + connection.prepare(json: {}); + await tester.tap(find.text('Mute topic')); + await tester.pump(); + await tester.pump(Duration.zero); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/user_topics') + ..bodyFields.deepEquals({ + 'stream_id': channel.streamId.toString(), + 'topic': 'topic foo', + 'visibility_policy': UserTopicVisibilityPolicy.muted.apiValue.toString(), + }); + }); + + testWidgets('sort topics by maxId', (tester) async { + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(name: 'A', maxId: 3), + eg.getStreamTopicsEntry(name: 'B', maxId: 2), + eg.getStreamTopicsEntry(name: 'C', maxId: 4), + ]); + + check(findInTopicItemAt(0, find.text('C'))).findsOne(); + check(findInTopicItemAt(1, find.text('A'))).findsOne(); + check(findInTopicItemAt(2, find.text('B'))).findsOne(); + }); + + testWidgets('resolved and unresolved topics', (tester) async { + final resolvedTopic = TopicName('resolved').resolve(); + final unresolvedTopic = TopicName('unresolved'); + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: resolvedTopic.apiName), + eg.getStreamTopicsEntry(maxId: 1, name: unresolvedTopic.apiName), + ]); + + assert(resolvedTopic.displayName == '✔ resolved', resolvedTopic.displayName); + check(findInTopicItemAt(0, find.text('✔ resolved'))).findsNothing(); + + check(findInTopicItemAt(0, find.text('resolved'))).findsOne(); + check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check).hitTestable())) + .findsOne(); + + check(findInTopicItemAt(1, find.text('unresolved'))).findsOne(); + check(findInTopicItemAt(1, find.byType(Icon)).hitTestable()) + .findsNothing(); + }); + + testWidgets('handle empty topics', (tester) async { + await prepare(tester, topics: [ + eg.getStreamTopicsEntry(name: ''), + ]); + check(findInTopicItemAt(0, + find.text(eg.defaultRealmEmptyTopicDisplayName))).findsOne(); + }); + + group('unreads', () { + testWidgets('muted and non-muted topics', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: 'muted'), + eg.getStreamTopicsEntry(maxId: 1, name: 'non-muted'), + ], + userTopics: [ + eg.userTopicItem(channel, 'muted', UserTopicVisibilityPolicy.muted), + ], + messages: [ + eg.streamMessage(stream: channel, topic: 'muted'), + eg.streamMessage(stream: channel, topic: 'non-muted'), + eg.streamMessage(stream: channel, topic: 'non-muted'), + ]); + + check(findInTopicItemAt(0, find.text('1'))).findsOne(); + check(findInTopicItemAt(0, find.text('muted'))).findsOne(); + check(findInTopicItemAt(0, find.byIcon(ZulipIcons.mute).hitTestable())) + .findsOne(); + + check(findInTopicItemAt(1, find.text('2'))).findsOne(); + check(findInTopicItemAt(1, find.text('non-muted'))).findsOne(); + check(findInTopicItemAt(1, find.byType(Icon).hitTestable())) + .findsNothing(); + }); + + testWidgets('with and without unread mentions', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [ + eg.getStreamTopicsEntry(maxId: 2, name: 'not mentioned'), + eg.getStreamTopicsEntry(maxId: 1, name: 'mentioned'), + ], + messages: [ + eg.streamMessage(stream: channel, topic: 'not mentioned'), + eg.streamMessage(stream: channel, topic: 'not mentioned'), + eg.streamMessage(stream: channel, topic: 'not mentioned', + flags: [MessageFlag.mentioned, MessageFlag.read]), + eg.streamMessage(stream: channel, topic: 'mentioned', + flags: [MessageFlag.mentioned]), + ]); + + check(findInTopicItemAt(0, find.text('2'))).findsOne(); + check(findInTopicItemAt(0, find.text('not mentioned'))).findsOne(); + check(findInTopicItemAt(0, find.byType(Icons))).findsNothing(); + + check(findInTopicItemAt(1, find.text('1'))).findsOne(); + check(findInTopicItemAt(1, find.text('mentioned'))).findsOne(); + check(findInTopicItemAt(1, find.byIcon(ZulipIcons.at_sign))).findsOne(); + }); + }); + + group('topic visibility', () { + testWidgets('default', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')]); + + check(find.descendant(of: topicItemFinder, + matching: find.byType(Icons))).findsNothing(); + }); + + testWidgets('muted', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.muted), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.mute))).findsOne(); + }); + + testWidgets('unmuted', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.unmuted), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.unmute))).findsOne(); + }); + + testWidgets('followed', (tester) async { + final channel = eg.stream(); + await prepare(tester, channel: channel, + topics: [eg.getStreamTopicsEntry(name: 'topic')], + userTopics: [ + eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.followed), + ]); + check(find.descendant(of: topicItemFinder, + matching: find.byIcon(ZulipIcons.follow))).findsOne(); + }); + }); +} From d81b812af4aef99a9e5789c30ffe986ffe991cd1 Mon Sep 17 00:00:00 2001 From: lakshya1goel Date: Thu, 1 May 2025 20:38:51 +0530 Subject: [PATCH 085/290] topics: Add TopicListButton to channel action sheet The icon was taken from CZO discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/Topic.20list.20in.20channel/near/2140324 Fixes: #1158 Co-authored-by: Zixuan James Li --- assets/icons/ZulipIcons.ttf | Bin 14108 -> 14384 bytes assets/icons/topics.svg | 3 ++ assets/l10n/app_en.arb | 4 ++ lib/generated/l10n/zulip_localizations.dart | 6 +++ .../l10n/zulip_localizations_ar.dart | 3 ++ .../l10n/zulip_localizations_de.dart | 3 ++ .../l10n/zulip_localizations_en.dart | 3 ++ .../l10n/zulip_localizations_ja.dart | 3 ++ .../l10n/zulip_localizations_nb.dart | 3 ++ .../l10n/zulip_localizations_pl.dart | 3 ++ .../l10n/zulip_localizations_ru.dart | 3 ++ .../l10n/zulip_localizations_sk.dart | 3 ++ .../l10n/zulip_localizations_uk.dart | 3 ++ lib/widgets/action_sheet.dart | 40 +++++++++++++----- lib/widgets/icons.dart | 7 ++- test/widgets/action_sheet_test.dart | 16 ++++++- 16 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 assets/icons/topics.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 84e19a9cfaed21884cc933dd7b92621425d79f1d..0ef0c3f461d17e792be725867eb5298a77c384c8 100644 GIT binary patch delta 1981 zcmb7FOH5-`82(Nlmq!a@p-kHuMW8r?(C6)KOG^u-z!>9XOolKaCJL3|r9A3@2%30V zx|q%6ZdtgPxY31*3!`XA#<(^yAu(|=8xj{26UgX7Blw-m;o;I=zWe{@fB*kE_pr6} zdDVmf;K3%GNW3&URvvwH=Lh#nKaK7I?jWrurxq*K!1vQn0qs6z`s~8S zO!()wmH_Wf;QIL7bmi{a{tsW$_A!x}qo7u}M#%3FopXz;>z}@S{11KCnZv)ZJXNfG z_4jwwd3O1_zgStXiq}PkN|~^2sj@iT@^wiEQV#(~wz|Bs`bTo&2uT0R_3Z~c;f4{r z19tIk)5z@yPm*H9Y8A8Q8XD1oPw*pt!=Iv4Oo}bBD-InCj;BsI$Ki$-G2Fl#ScL=6 zAj&6(I1)&~KpGixQDo7J9C;^4bZ&7jcT=9>6G0#C67^T89fYcHq1n zSZC!fJdXw@j38+zG#JRBwnV!;3)2up2({GfEMyJ6J%35{iT;IqJ>sPQ&oDI3mpZz4I@A&og<2=zM7a`4t?o56<%^G&zMc-R^4dx80tWNGugh@_!M(%AAVLIn* zx$HN~gn6{H7=zXsuH~rnoQ(U-s#Eqvjr4D08wPEXc!AtWe3?4J(=?75+{1^cH@T~F zBm;JbI@6pVnmT*ja;N)kxxvseS{jtslGE*=eSGIRT8)8=)I~{a?UGJBJ!)1mN&^d# z8#8F12aU&-`=6n(9^D-LEj-JeHyAF@Ao2hum|T`8o#jNHW9t9v(_BlaC+uY^&!1B5WM2~gTy=#5!c3ED{ zj33fb{+_UU$wf%(c@Z2k@GjT&?B2m=0`u57=&`=h!pQ`!3XGd%;xrq}af|wyx5&ce zJj$#h&yt5Y-8cs^Xf@W1xqBE@vwT|RD*X$6Nh95xqhG41$4cdBJ~K2BA0D(0YSyhb z?ULzbZa7Qw&EGIFp^zZ0C`3r#R49;6DfE%vRY;OfD;T6R3Te_=1>U=uQ^=9doAO0u ziMJK{Nf#7Sq>BngQeMV3cxPf+Ax2tND3QLSP$s>n5G7qv$dj%r6nPorU4h^`m zZ#D&kO-Gx)P`B8dQ}RH-u_GieJbn-VgfAHMsh5;;=|odVggS`dPnk#dTD}~@X1)H) oe(y2P#K&jcwl}fw;(xP4<0FT-vHgB}QfRBo)%mFv>z7RVUp~waG5`Po delta 1698 zcmb7^%WoT16vn@CY{yBQq|g?Uwv^C>5@I{{*zqfN65EM@N{9j>2%(79qAPB(%iO27bZ(3O5tIy}oJ@4<_nd^;f z>rFY5y7ao*lzMb_uJ&|gjsvG4we6jqO{SmSo86N_5f0BUtkoN(Z=dOx!rPn;EwAn@ zo$b|EQgm24-B?+yU)Z|!`G?qk4RR|SM4aFZ`x{_rWo>i&{WE|6iSG(I4y|r1Ow>Pk z>K%f2UBlpwwfc6$nRjw1`*{5A9_p6(PF{S*383JX{PGMK1u(0~nuLydVQfkON`eXPdu$=HC^zf9zC+ph} z&`ea#1nJ9|n?9;ICfH@vOTYvbru~9U_yIkHc>(JTD@o!E`yzT*!?2X#BuAE=2@N%wMA}A=(g36-&1Rr1GJIBCZz}2wGx=rac<9ZB)+K2aMSHGGU+$yD%oUn zaCtg>g3V+nNN`Lu`SZbNmOgznX-az8Vgk8p!RJqF7G>nRg^S#q8LapWb7B68Cri+mMY`4b#UTNabZ*DOYneDr-7hIT9}yB~M_;dq~Q&F0@e iT=@S>+I>8FYjC&Uy&2@w{a5zN?v>nm$NM>7b@UJAG}@*B diff --git a/assets/icons/topics.svg b/assets/icons/topics.svg new file mode 100644 index 0000000000..c07afa80b3 --- /dev/null +++ b/assets/icons/topics.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1c0ec3d7e8..91206fabc6 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -84,6 +84,10 @@ "@actionSheetOptionMarkChannelAsRead": { "description": "Label for marking a channel as read." }, + "actionSheetOptionListOfTopics": "List of topics", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, "actionSheetOptionMuteTopic": "Mute topic", "@actionSheetOptionMuteTopic": { "description": "Label for muting a topic on action sheet." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 566ff617ad..675407f68d 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -239,6 +239,12 @@ abstract class ZulipLocalizations { /// **'Mark channel as read'** String get actionSheetOptionMarkChannelAsRead; + /// Label for navigating to a channel's topic-list page. + /// + /// In en, this message translates to: + /// **'List of topics'** + String get actionSheetOptionListOfTopics; + /// Label for muting a topic 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 d1025b7151..febbb2c585 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index eee1309e1b..a9188773c0 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index fbdcea3935..73a4212ee3 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index a9a2e40dce..b614f71cbb 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index afe3c9794a..4929eae453 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Mute topic'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 7653f8d31d..a5ee696797 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -78,6 +78,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionMarkChannelAsRead => 'Oznacz kanał jako przeczytany'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Wycisz wątek'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 6ea916c359..1fcde0d7a2 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -78,6 +78,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionMarkChannelAsRead => 'Отметить канал как прочитанный'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Отключить тему'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 5430a885c4..30cd4c89f8 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -76,6 +76,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Stlmiť tému'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index db213893db..a77d28aeff 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -79,6 +79,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionMarkChannelAsRead => 'Позначити канал як прочитаний'; + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + @override String get actionSheetOptionMuteTopic => 'Заглушити тему'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 04e535e65a..6bd4e1024a 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -29,6 +29,7 @@ import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; void _showActionSheet( BuildContext context, { @@ -175,24 +176,43 @@ void showChannelActionSheet(BuildContext context, { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); - final optionButtons = []; + final optionButtons = [ + TopicListButton(pageContext: pageContext, channelId: channelId), + ]; + final unreadCount = store.unreads.countInChannelNarrow(channelId); if (unreadCount > 0) { optionButtons.add( MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId)); } - 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 - // though it offers a gesture interaction that it doesn't meaningfully - // offer, which is confusing. The solution here is probably to remove this - // is-empty case by having at least one button that's always present, - // such as "copy link to channel". - return; - } + _showActionSheet(pageContext, optionButtons: optionButtons); } +class TopicListButton extends ActionSheetMenuItemButton { + const TopicListButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.topics; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionListOfTopics; + } + + @override + void onPressed() { + Navigator.push(pageContext, + TopicListPage.buildRoute(context: pageContext, streamId: channelId)); + } +} + class MarkChannelAsReadButton extends ActionSheetMenuItemButton { const MarkChannelAsReadButton({ super.key, diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index bab7b152de..be088afd48 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -144,11 +144,14 @@ abstract final class ZulipIcons { /// The Zulip custom icon "topic". static const IconData topic = IconData(0xf128, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "topics". + static const IconData topics = IconData(0xf129, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf12b, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 6b8011510a..16cc36b096 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -226,6 +226,7 @@ void main() { group('showChannelActionSheet', () { void checkButtons() { check(actionSheetFinder).findsOne(); + checkButton('List of topics'); checkButton('Mark channel as read'); } @@ -244,7 +245,7 @@ void main() { testWidgets('show with no unread messages', (tester) async { await prepare(hasUnreadMessages: false); await showFromSubscriptionList(tester); - check(actionSheetFinder).findsNothing(); + check(findButtonForLabel('Mark channel as read')).findsNothing(); }); testWidgets('show from app bar in channel narrow', (tester) async { @@ -268,6 +269,19 @@ void main() { }); }); + testWidgets('TopicListButton', (tester) async { + await prepare(); + await showFromAppBar(tester, + narrow: ChannelNarrow(someChannel.streamId)); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'some topic foo'), + ]).toJson()); + await tester.tap(findButtonForLabel('List of topics')); + await tester.pumpAndSettle(); + check(find.text('some topic foo')).findsOne(); + }); + group('MarkChannelAsReadButton', () { void checkRequest(int channelId) { check(connection.takeRequests()).single.isA() From 86f62c9b1d381d182d965343e0285b4d5fbf77b4 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 23 Apr 2025 12:32:59 -0400 Subject: [PATCH 086/290] msglist [nfc]: Make trailingWhitespace a constant This 11px whitespace can be traced back to 311d4d56e in 2022. While this is not present in the Figma design, it would be a good idea to refine it in the future. See discussion: https://github.com/zulip/zulip-flutter/pull/1453#discussion_r2106526985 --- lib/widgets/message_list.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 0003ae5ed7..3bc7d60363 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -750,7 +750,6 @@ class _MessageListState extends State with PerAccountStoreAwareStat return MessageItem( key: ValueKey(data.message.id), header: header, - trailingWhitespace: 11, item: data); } } @@ -1048,12 +1047,10 @@ class MessageItem extends StatelessWidget { super.key, required this.item, required this.header, - this.trailingWhitespace, }); final MessageListMessageBaseItem item; final Widget header; - final double? trailingWhitespace; @override Widget build(BuildContext context) { @@ -1066,7 +1063,9 @@ class MessageItem extends StatelessWidget { switch (item) { MessageListMessageItem() => MessageWithPossibleSender(item: item), }, - if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), + // TODO refine this padding; discussion: + // https://github.com/zulip/zulip-flutter/pull/1453#discussion_r2106526985 + if (item.isLastInBlock) const SizedBox(height: 11), ])); if (item case MessageListMessageItem(:final message)) { child = _UnreadMarker( From 75f0debbf2ec10af71598ce07c01a9e23a9c74c4 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 26 May 2025 20:30:39 -0400 Subject: [PATCH 087/290] narrow test: Make sure sender is selfUser for outbox DM messages [chris: expanded commit-message summary line] Co-authored-by: Chris Bobbe --- test/model/narrow_test.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/model/narrow_test.dart b/test/model/narrow_test.dart index 9d68873670..c61dcf0dbc 100644 --- a/test/model/narrow_test.dart +++ b/test/model/narrow_test.dart @@ -25,10 +25,11 @@ void main() { } DmOutboxMessage dmOutboxMessage({required List allRecipientIds}) { + final senderUserId = allRecipientIds[0]; return OutboxMessage.fromConversation( - DmConversation(allRecipientIds: allRecipientIds), + DmConversation(allRecipientIds: allRecipientIds..sort()), localMessageId: nextLocalMessageId++, - selfUserId: allRecipientIds[0], + selfUserId: senderUserId, timestamp: 123456789, contentMarkdown: 'content') as DmOutboxMessage; } @@ -228,7 +229,7 @@ void main() { check(narrow.containsMessage( dmOutboxMessage(allRecipientIds: [2, 3]))).isFalse(); check(narrow.containsMessage( - dmOutboxMessage(allRecipientIds: [1, 2]))).isTrue(); + dmOutboxMessage(allRecipientIds: [2, 1]))).isTrue(); }); }); From 815b9d2f446aca3a24987375770340642e9855fb Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 13:38:07 -0400 Subject: [PATCH 088/290] test [nfc]: Extract {dm,stream}OutboxMessage helpers --- test/example_data.dart | 40 +++++++++++++++++++++++++ test/model/narrow_test.dart | 59 +++++++++++-------------------------- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index 93869df37a..b87cbb6dc8 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -12,6 +12,7 @@ import 'package:zulip/api/route/realm.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/database.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; @@ -625,6 +626,45 @@ GetMessagesResult olderGetMessagesResult({ ); } +int _nextLocalMessageId = 1; + +StreamOutboxMessage streamOutboxMessage({ + int? localMessageId, + int? selfUserId, + int? timestamp, + ZulipStream? stream, + String? topic, + String? content, +}) { + final effectiveStream = stream ?? _stream(streamId: defaultStreamMessageStreamId); + return OutboxMessage.fromConversation( + StreamConversation( + effectiveStream.streamId, TopicName(topic ?? 'topic'), + displayRecipient: null, + ), + localMessageId: localMessageId ?? _nextLocalMessageId++, + selfUserId: selfUserId ?? selfUser.userId, + timestamp: timestamp ?? utcTimestamp(), + contentMarkdown: content ?? 'content') as StreamOutboxMessage; +} + +DmOutboxMessage dmOutboxMessage({ + int? localMessageId, + required User from, + required List to, + int? timestamp, + String? content, +}) { + final allRecipientIds = + [from, ...to].map((user) => user.userId).toList()..sort(); + return OutboxMessage.fromConversation( + DmConversation(allRecipientIds: allRecipientIds), + localMessageId: localMessageId ?? _nextLocalMessageId++, + selfUserId: from.userId, + timestamp: timestamp ?? utcTimestamp(), + contentMarkdown: content ?? 'content') as DmOutboxMessage; +} + PollWidgetData pollWidgetData({ required String question, required List options, diff --git a/test/model/narrow_test.dart b/test/model/narrow_test.dart index c61dcf0dbc..c62c56438c 100644 --- a/test/model/narrow_test.dart +++ b/test/model/narrow_test.dart @@ -2,38 +2,12 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; -import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import '../example_data.dart' as eg; import 'narrow_checks.dart'; void main() { - int nextLocalMessageId = 1; - - StreamOutboxMessage streamOutboxMessage({ - required ZulipStream stream, - required String topic, - }) { - return OutboxMessage.fromConversation( - StreamConversation( - stream.streamId, TopicName(topic), displayRecipient: null), - localMessageId: nextLocalMessageId++, - selfUserId: eg.selfUser.userId, - timestamp: 123456789, - contentMarkdown: 'content') as StreamOutboxMessage; - } - - DmOutboxMessage dmOutboxMessage({required List allRecipientIds}) { - final senderUserId = allRecipientIds[0]; - return OutboxMessage.fromConversation( - DmConversation(allRecipientIds: allRecipientIds..sort()), - localMessageId: nextLocalMessageId++, - selfUserId: senderUserId, - timestamp: 123456789, - contentMarkdown: 'content') as DmOutboxMessage; - } - group('SendableNarrow', () { test('ofMessage: stream message', () { final message = eg.streamMessage(); @@ -61,11 +35,11 @@ void main() { eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); check(narrow.containsMessage( - dmOutboxMessage(allRecipientIds: [1]))).isFalse(); + eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]))).isFalse(); check(narrow.containsMessage( - streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); check(narrow.containsMessage( - streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); + eg.streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); }); }); @@ -91,13 +65,13 @@ void main() { eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); check(narrow.containsMessage( - dmOutboxMessage(allRecipientIds: [1]))).isFalse(); + eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]))).isFalse(); check(narrow.containsMessage( - streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); check(narrow.containsMessage( - streamOutboxMessage(stream: stream, topic: 'topic2'))).isFalse(); + eg.streamOutboxMessage(stream: stream, topic: 'topic2'))).isFalse(); check(narrow.containsMessage( - streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); + eg.streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); }); }); @@ -220,16 +194,19 @@ void main() { }); test('containsMessage with non-Message', () { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); final narrow = DmNarrow(allRecipientIds: [1, 2], selfUserId: 2); check(narrow.containsMessage( - streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - dmOutboxMessage(allRecipientIds: [2]))).isFalse(); + eg.dmOutboxMessage(from: user2, to: []))).isFalse(); check(narrow.containsMessage( - dmOutboxMessage(allRecipientIds: [2, 3]))).isFalse(); + eg.dmOutboxMessage(from: user2, to: [user3]))).isFalse(); check(narrow.containsMessage( - dmOutboxMessage(allRecipientIds: [2, 1]))).isTrue(); + eg.dmOutboxMessage(from: user2, to: [user1]))).isTrue(); }); }); @@ -245,9 +222,9 @@ void main() { eg.streamMessage(flags: [MessageFlag.wildcardMentioned]))).isTrue(); check(narrow.containsMessage( - streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - dmOutboxMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); + eg.dmOutboxMessage(from: eg.selfUser, to: []))).isFalse(); }); }); @@ -261,9 +238,9 @@ void main() { eg.streamMessage(flags:[MessageFlag.starred]))).isTrue(); check(narrow.containsMessage( - streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + eg.streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - dmOutboxMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); + eg.dmOutboxMessage(from: eg.selfUser, to: []))).isFalse(); }); }); } From 2829bd847caf3435b900db1413612f783b4dd72e Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 24 Apr 2025 14:12:23 -0400 Subject: [PATCH 089/290] msglist test [nfc]: Make checkInvariant compatible with MessageBase --- test/api/model/model_checks.dart | 1 + test/model/message_list_test.dart | 35 ++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 3ae106afcc..17bd86ee9e 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -37,6 +37,7 @@ extension TopicNameChecks on Subject { } extension StreamConversationChecks on Subject { + Subject get streamId => has((x) => x.streamId, 'streamId'); Subject get topic => has((x) => x.topic, 'topic'); Subject get displayRecipient => has((x) => x.displayRecipient, 'displayRecipient'); } diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index ac41d771ec..11f2b3056b 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -2153,15 +2153,21 @@ void checkInvariants(MessageListView model) { for (final message in model.messages) { check(model.store.messages)[message.id].isNotNull().identicalTo(message); + } + + final allMessages = >[...model.messages]; + + for (final message in allMessages) { check(model.narrow.containsMessage(message)).isTrue(); - if (message is! StreamMessage) continue; + if (message is! MessageBase) continue; + final conversation = message.conversation; switch (model.narrow) { case CombinedFeedNarrow(): - check(model.store.isTopicVisible(message.streamId, message.topic)) + check(model.store.isTopicVisible(conversation.streamId, conversation.topic)) .isTrue(); case ChannelNarrow(): - check(model.store.isTopicVisibleInStream(message.streamId, message.topic)) + check(model.store.isTopicVisibleInStream(conversation.streamId, conversation.topic)) .isTrue(); case TopicNarrow(): case DmNarrow(): @@ -2204,23 +2210,28 @@ void checkInvariants(MessageListView model) { } int i = 0; - for (int j = 0; j < model.messages.length; j++) { + for (int j = 0; j < allMessages.length; j++) { bool forcedShowSender = false; if (j == 0 - || !haveSameRecipient(model.messages[j-1], model.messages[j])) { + || !haveSameRecipient(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() - .message.identicalTo(model.messages[j]); + .message.identicalTo(allMessages[j]); forcedShowSender = true; - } else if (!messagesSameDay(model.messages[j-1], model.messages[j])) { + } else if (!messagesSameDay(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() - .message.identicalTo(model.messages[j]); + .message.identicalTo(allMessages[j]); forcedShowSender = true; } - check(model.items[i++]).isA() - ..message.identicalTo(model.messages[j]) - ..content.identicalTo(model.contents[j]) + if (j < model.messages.length) { + check(model.items[i]).isA() + ..message.identicalTo(model.messages[j]) + ..content.identicalTo(model.contents[j]); + } else { + assert(false); + } + check(model.items[i++]).isA() ..showSender.equals( - forcedShowSender || model.messages[j].senderId != model.messages[j-1].senderId) + forcedShowSender || allMessages[j].senderId != allMessages[j-1].senderId) ..isLastInBlock.equals( i == model.items.length || switch (model.items[i]) { MessageListMessageItem() From a4c564b1cdde4fcc864ea353041544fca692b80b Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 1 Apr 2025 15:55:29 -0400 Subject: [PATCH 090/290] msglist [nfc]: Extract _addItemsForMessage Also removed a stale comment that refers to resolved issues (#173 and #175). We will reuse this helper when processing items for outbox messages. --- lib/model/message_list.dart | 65 +++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 4da9ebd3cc..8022ba8a7d 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -322,24 +322,31 @@ mixin _MessageSequence { _reprocessAll(); } - /// Append to [items] based on the index-th message and its content. + /// Append to [items] based on [message] and [prevMessage]. /// - /// The previous messages in the list must already have been processed. - /// This message must already have been parsed and reflected in [contents]. - void _processMessage(int index) { - // This will get more complicated to handle the ways that messages interact - // with the display of neighboring messages: sender headings #175 - // and date separators #173. - final message = messages[index]; - final content = contents[index]; - bool canShareSender; - if (index == 0 || !haveSameRecipient(messages[index - 1], message)) { + /// This appends a recipient header or a date separator to [items], + /// depending on how [prevMessage] relates to [message], + /// and then the result of [buildItem], updating [middleItem] if desired. + /// + /// See [middleItem] to determine the value of [shouldSetMiddleItem]. + /// + /// [prevMessage] should be the message that visually appears before [message]. + /// + /// The caller must ensure that [prevMessage] and all messages before it + /// have been processed. + void _addItemsForMessage(MessageBase message, { + required bool shouldSetMiddleItem, + required MessageBase? prevMessage, + required MessageListMessageBaseItem Function(bool canShareSender) buildItem, + }) { + final bool canShareSender; + if (prevMessage == null || !haveSameRecipient(prevMessage, message)) { items.add(MessageListRecipientHeaderItem(message)); canShareSender = false; } else { - assert(items.last is MessageListMessageItem); - final prevMessageItem = items.last as MessageListMessageItem; - assert(identical(prevMessageItem.message, messages[index - 1])); + assert(items.last is MessageListMessageBaseItem); + final prevMessageItem = items.last as MessageListMessageBaseItem; + assert(identical(prevMessageItem.message, prevMessage)); assert(prevMessageItem.isLastInBlock); prevMessageItem.isLastInBlock = false; @@ -347,12 +354,34 @@ mixin _MessageSequence { items.add(MessageListDateSeparatorItem(message)); canShareSender = false; } else { - canShareSender = (prevMessageItem.message.senderId == message.senderId); + canShareSender = prevMessageItem.message.senderId == message.senderId; } } - if (index == middleMessage) middleItem = items.length; - items.add(MessageListMessageItem(message, content, - showSender: !canShareSender, isLastInBlock: true)); + final item = buildItem(canShareSender); + assert(identical(item.message, message)); + assert(item.showSender == !canShareSender); + assert(item.isLastInBlock); + if (shouldSetMiddleItem) { + assert(item is MessageListMessageItem); + middleItem = items.length; + } + items.add(item); + } + + /// Append to [items] based on the index-th message and its content. + /// + /// The previous messages in the list must already have been processed. + /// This message must already have been parsed and reflected in [contents]. + void _processMessage(int index) { + final prevMessage = index == 0 ? null : messages[index - 1]; + final message = messages[index]; + final content = contents[index]; + + _addItemsForMessage(message, + shouldSetMiddleItem: index == middleMessage, + prevMessage: prevMessage, + buildItem: (bool canShareSender) => MessageListMessageItem( + message, content, showSender: !canShareSender, isLastInBlock: true)); } /// Recompute [items] from scratch, based on [messages], [contents], and flags. From 288006529500b1df5488246d18a805465510f8ac Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Wed, 28 May 2025 14:05:33 +0200 Subject: [PATCH 091/290] l10n: Update translations from Weblate. --- assets/l10n/app_pl.arb | 8 +++ assets/l10n/app_ru.arb | 72 ++++++++++++++++++- .../l10n/zulip_localizations_pl.dart | 4 +- .../l10n/zulip_localizations_ru.dart | 36 +++++----- 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 8de9527def..468e2136b2 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1072,5 +1072,13 @@ "composeBoxBannerButtonSave": "Zapisz", "@composeBoxBannerButtonSave": { "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "topicsButtonLabel": "WĄTKI", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "actionSheetOptionListOfTopics": "Lista wątków", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 57cf48e3e0..bf36f0c900 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -113,7 +113,7 @@ } } }, - "errorCouldNotFetchMessageSource": "Не удалось извлечь источник сообщения", + "errorCouldNotFetchMessageSource": "Не удалось извлечь источник сообщения.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -393,7 +393,7 @@ "@serverUrlValidationErrorNoUseEmail": { "description": "Error message when URL looks like an email" }, - "errorVideoPlayerFailed": "Не удается воспроизвести видео", + "errorVideoPlayerFailed": "Не удается воспроизвести видео.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, @@ -525,7 +525,7 @@ "@successMessageTextCopied": { "description": "Message when content of a message was copied to the user's system clipboard." }, - "errorInvalidResponse": "Получен недопустимый ответ сервера", + "errorInvalidResponse": "Сервер отправил недопустимый ответ.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, @@ -1006,5 +1006,71 @@ "experimentalFeatureSettingsWarning": "Эти параметры включают функции, которые все еще находятся в стадии разработки и не готовы. Они могут не работать и вызывать проблемы в других местах приложения.\n\nЦель этих настроек — экспериментирование людьми, работающими над разработкой Zulip.", "@experimentalFeatureSettingsWarning": { "description": "Warning text on settings page for experimental, in-development features" + }, + "errorCouldNotEditMessageTitle": "Сбой редактирования", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxBannerButtonSave": "Сохранить", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Редактирование недоступно", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "ЗАПИСЬ ПРАВОК…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "ПРАВКИ НЕ СОХРАНЕНЫ", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Отказаться от написанного сообщения?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Сбросить", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxBannerButtonCancel": "Отмена", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "actionSheetOptionEditMessage": "Редактировать сообщение", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorMessageEditNotSaved": "Сообщение не сохранено", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "preparingEditMessageContentInput": "Подготовка…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxEnterTopicOrSkipHintText": "Укажите тему (или оставьте “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "composeBoxBannerLabelEditMessage": "Редактирование сообщения", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressMessage": "Редактирование уже выполняется. Дождитесь завершения.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "discardDraftConfirmationDialogMessage": "При изменении сообщения текст из поля для редактирования удаляется.", + "@discardDraftConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." } } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index a5ee696797..3af0e94f46 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -79,7 +79,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Oznacz kanał jako przeczytany'; @override - String get actionSheetOptionListOfTopics => 'List of topics'; + String get actionSheetOptionListOfTopics => 'Lista wątków'; @override String get actionSheetOptionMuteTopic => 'Wycisz wątek'; @@ -642,7 +642,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get mainMenuMyProfile => 'Mój profil'; @override - String get topicsButtonLabel => 'TOPICS'; + String get topicsButtonLabel => 'WĄTKI'; @override String get channelFeedButtonTooltip => 'Strumień kanału'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 1fcde0d7a2..72a87e6c32 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -131,7 +131,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'Редактировать сообщение'; @override String get actionSheetOptionMarkTopicAsRead => @@ -153,7 +153,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Не удалось извлечь источник сообщения'; + 'Не удалось извлечь источник сообщения.'; @override String get errorCopyingFailed => 'Сбой копирования'; @@ -204,7 +204,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorMessageNotSent => 'Сообщение не отправлено'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Сообщение не сохранено'; @override String errorLoginCouldNotConnect(String url) { @@ -280,7 +280,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'Не удалось снять отметку с сообщения'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => 'Сбой редактирования'; @override String get successLinkCopied => 'Ссылка скопирована'; @@ -300,37 +300,37 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'У вас нет права писать в этом канале.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Редактирование сообщения'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Отмена'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Сохранить'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => 'Редактирование недоступно'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Редактирование уже выполняется. Дождитесь завершения.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'ЗАПИСЬ ПРАВОК…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ СОХРАНЕНЫ'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + 'Отказаться от написанного сообщения?'; @override String get discardDraftConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + 'При изменении сообщения текст из поля для редактирования удаляется.'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; @override String get composeBoxAttachFilesTooltip => 'Прикрепить файлы'; @@ -361,7 +361,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Подготовка…'; @override String get composeBoxSendTooltip => 'Отправить'; @@ -374,7 +374,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Укажите тему (или оставьте “$defaultTopicName”)'; } @override @@ -517,7 +517,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Получен недопустимый ответ сервера'; + String get errorInvalidResponse => 'Сервер отправил недопустимый ответ.'; @override String get errorNetworkRequestFailed => 'Сбой сетевого запроса'; @@ -538,7 +538,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Не удается воспроизвести видео'; + String get errorVideoPlayerFailed => 'Не удается воспроизвести видео.'; @override String get serverUrlValidationErrorEmpty => 'Пожалуйста, введите URL-адрес.'; From 1bb82068a85ff79d9ac4e52208d05e798bf913c8 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 28 May 2025 23:24:06 -0700 Subject: [PATCH 092/290] version: Sync version and changelog from v0.0.30 release --- docs/changelog.md | 30 ++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index d2d50c022b..b806d82aeb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,36 @@ ## Unreleased +## 0.0.30 (2025-05-28) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +We're nearing ready to have this new app replace the legacy +Zulip mobile app, a few weeks from now. + +In addition to all the features in the last beta: +* Muted users are now muted. (#296) +* Improved logic to recover from failed send. (#1441) +* Numerous small improvements to the newest features. + + +### Highlights for developers + +* Resolved in main: #83, #1495, #1456, #1158 + +* Resolved in the experimental branch: + * #82, and #80 behind a flag, via PR #1517 + * #1441 via PR #1453 + * #127 via PR #1322 + * more toward #46 via PR #1452 + * #1147 via PR #1379 + * #296 via PR #1429 + + ## 0.0.29 (2025-05-19) This is a preview beta, including some experimental changes diff --git a/pubspec.yaml b/pubspec.yaml index 1df9ed3390..b49bf1fd7d 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.29+29 +version: 0.0.30+30 environment: # We use a recent version of Flutter from its main channel, and From d6a4959283c87c79a1a3437f867351ab8dfb8206 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 20 May 2025 16:58:12 -0400 Subject: [PATCH 093/290] l10n: Add zh, zh-Hans-CN, and zh-Hant-TW Normally, instead of doing it manually, we would add new languages from Weblate's UI. In this case, the ones added in this commit are not offered there. So we will commit this to GitHub first, and let Weblate pull the changes from there. The language identifier zh is left empty, and is set to be ignored on Weblate for translations. Translator will be expected translate strings for zh-Hans-CN and zh-Hant-TW instead. We can add more locales with the zh language code if users request them. See CZO discussion on how we picked these language identifiers: https://chat.zulip.org/#narrow/channel/58-translation/topic/zh_*.20in.20Weblate/near/2177452 --- assets/l10n/app_zh.arb | 1 + assets/l10n/app_zh_Hans_CN.arb | 3 + assets/l10n/app_zh_Hant_TW.arb | 3 + lib/generated/l10n/zulip_localizations.dart | 23 + .../l10n/zulip_localizations_zh.dart | 792 ++++++++++++++++++ 5 files changed, 822 insertions(+) create mode 100644 assets/l10n/app_zh.arb create mode 100644 assets/l10n/app_zh_Hans_CN.arb create mode 100644 assets/l10n/app_zh_Hant_TW.arb create mode 100644 lib/generated/l10n/zulip_localizations_zh.dart diff --git a/assets/l10n/app_zh.arb b/assets/l10n/app_zh.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_zh.arb @@ -0,0 +1 @@ +{} diff --git a/assets/l10n/app_zh_Hans_CN.arb b/assets/l10n/app_zh_Hans_CN.arb new file mode 100644 index 0000000000..9766804e42 --- /dev/null +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -0,0 +1,3 @@ +{ + "settingsPageTitle": "设置" +} diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb new file mode 100644 index 0000000000..201cee2e56 --- /dev/null +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -0,0 +1,3 @@ +{ + "settingsPageTitle": "設定" +} diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 675407f68d..de0368bb6d 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -14,6 +14,7 @@ import 'zulip_localizations_pl.dart'; import 'zulip_localizations_ru.dart'; import 'zulip_localizations_sk.dart'; import 'zulip_localizations_uk.dart'; +import 'zulip_localizations_zh.dart'; // ignore_for_file: type=lint @@ -111,6 +112,17 @@ abstract class ZulipLocalizations { Locale('ru'), Locale('sk'), Locale('uk'), + Locale('zh'), + Locale.fromSubtags( + languageCode: 'zh', + countryCode: 'CN', + scriptCode: 'Hans', + ), + Locale.fromSubtags( + languageCode: 'zh', + countryCode: 'TW', + scriptCode: 'Hant', + ), ]; /// Title for About Zulip page. @@ -1432,6 +1444,7 @@ class _ZulipLocalizationsDelegate 'ru', 'sk', 'uk', + 'zh', ].contains(locale.languageCode); @override @@ -1439,6 +1452,14 @@ class _ZulipLocalizationsDelegate } ZulipLocalizations lookupZulipLocalizations(Locale locale) { + // Lookup logic when language+script+country codes are specified. + switch (locale.toString()) { + case 'zh_Hans_CN': + return ZulipLocalizationsZhHansCn(); + case 'zh_Hant_TW': + return ZulipLocalizationsZhHantTw(); + } + // Lookup logic when language+country codes are specified. switch (locale.languageCode) { case 'en': @@ -1471,6 +1492,8 @@ ZulipLocalizations lookupZulipLocalizations(Locale locale) { return ZulipLocalizationsSk(); case 'uk': return ZulipLocalizationsUk(); + case 'zh': + return ZulipLocalizationsZh(); } throw FlutterError( diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart new file mode 100644 index 0000000000..59c3a57129 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -0,0 +1,792 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class ZulipLocalizationsZh extends ZulipLocalizations { + ZulipLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get aboutPageTitle => 'About Zulip'; + + @override + String get aboutPageAppVersion => 'App version'; + + @override + String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + + @override + String get aboutPageTapToView => 'Tap to view'; + + @override + String get chooseAccountPageTitle => 'Choose account'; + + @override + String get settingsPageTitle => 'Settings'; + + @override + String get switchAccountButton => 'Switch account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Your account at $url is taking a while to load.'; + } + + @override + String get tryAnotherAccountButton => 'Try another account'; + + @override + String get chooseAccountPageLogOutButton => 'Log out'; + + @override + String get logOutConfirmationDialogTitle => 'Log out?'; + + @override + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Log out'; + + @override + String get chooseAccountButtonAddAnAccount => 'Add an account'; + + @override + String get profileButtonSendDirectMessage => 'Send direct message'; + + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + + @override + String get permissionsNeededTitle => 'Permissions needed'; + + @override + String get permissionsNeededOpenSettings => 'Open settings'; + + @override + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + + @override + String get actionSheetOptionMuteTopic => 'Mute topic'; + + @override + String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + + @override + String get actionSheetOptionFollowTopic => 'Follow topic'; + + @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'; + + @override + String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + + @override + String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + + @override + String get actionSheetOptionShare => 'Share'; + + @override + String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + + @override + String get actionSheetOptionStarMessage => 'Star message'; + + @override + String get actionSheetOptionUnstarMessage => 'Unstar message'; + + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + + @override + String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + + @override + String get errorAccountLoggedInTitle => 'Account already logged in'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'The account $email at $server is already in your list of accounts.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Could not fetch message source.'; + + @override + String get errorCopyingFailed => 'Copying failed'; + + @override + String errorFailedToUploadFileTitle(String filename) { + 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( + num, + locale: localeName, + other: '$num files are', + one: 'File is', + ); + return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Files', + one: 'File', + ); + return '$_temp0 too large'; + } + + @override + String get errorLoginInvalidInputTitle => 'Invalid input'; + + @override + String get errorLoginFailedTitle => 'Login failed'; + + @override + String get errorMessageNotSent => 'Message not sent'; + + @override + String get errorMessageEditNotSaved => 'Message not saved'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Failed to connect to server:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Could not connect'; + + @override + String get errorMessageDoesNotSeemToExist => + 'That message does not seem to exist.'; + + @override + String get errorQuotationFailed => 'Quotation failed'; + + @override + String errorServerMessage(String message) { + return 'The server said:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Error connecting to Zulip. Retrying…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Error handling a Zulip event. Retrying connection…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Link could not be opened: $url'; + } + + @override + String get errorMuteTopicFailed => 'Failed to mute topic'; + + @override + String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + + @override + String get errorFollowTopicFailed => 'Failed to follow topic'; + + @override + String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + + @override + String get errorSharingFailed => 'Sharing failed'; + + @override + String get errorStarMessageFailedTitle => 'Failed to star message'; + + @override + String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + + @override + String get successLinkCopied => 'Link copied'; + + @override + String get successMessageTextCopied => 'Message text copied'; + + @override + String get successMessageLinkCopied => 'Message link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + + @override + String get composeBoxAttachFilesTooltip => 'Attach files'; + + @override + String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + + @override + String get composeBoxGenericContentHint => 'Type a message'; + + @override + String composeBoxDmContentHint(String user) { + return 'Message @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Message group'; + + @override + String get composeBoxSelfDmContentHint => 'Jot down something'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Preparing…'; + + @override + String get composeBoxSendTooltip => 'Send'; + + @override + String get unknownChannelName => '(unknown channel)'; + + @override + String get composeBoxTopicHintText => 'Topic'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Uploading $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + + @override + String get unknownUserName => '(unknown user)'; + + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'You and $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + + @override + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; + + @override + String get contentValidationErrorEmpty => 'You have nothing to send!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogContinue => 'Continue'; + + @override + String get dialogClose => 'Close'; + + @override + String get errorDialogLearnMore => 'Learn more'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => 'Error'; + + @override + String get snackBarDetails => 'Details'; + + @override + String get lightboxCopyLinkTooltip => 'Copy link'; + + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + + @override + String get loginPageTitle => 'Log in'; + + @override + String get loginFormSubmitLabel => 'Log in'; + + @override + String get loginMethodDivider => 'OR'; + + @override + String signInWithFoo(String method) { + return 'Sign in with $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Add an account'; + + @override + String get loginServerUrlLabel => 'Your Zulip server URL'; + + @override + String get loginHidePassword => 'Hide password'; + + @override + String get loginEmailLabel => 'Email address'; + + @override + String get loginErrorMissingEmail => 'Please enter your email.'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginErrorMissingPassword => 'Please enter your password.'; + + @override + String get loginUsernameLabel => 'Username'; + + @override + String get loginErrorMissingUsername => 'Please enter your username.'; + + @override + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + + @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.'; + + @override + String get errorNetworkRequestFailed => 'Network request failed'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Server gave malformed response; HTTP status $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Server gave malformed response; HTTP status $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Network request failed: HTTP status $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Unable to play the video.'; + + @override + String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Mark all messages as read'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as read.'; + } + + @override + String get markAsReadInProgress => 'Marking messages as read…'; + + @override + String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as unread.'; + } + + @override + String get markAsUnreadInProgress => 'Marking messages as unread…'; + + @override + String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + + @override + String get today => 'Today'; + + @override + String get yesterday => 'Yesterday'; + + @override + String get userRoleOwner => 'Owner'; + + @override + String get userRoleAdministrator => 'Administrator'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Member'; + + @override + String get userRoleGuest => 'Guest'; + + @override + String get userRoleUnknown => 'Unknown'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get recentDmConversationsPageTitle => 'Direct messages'; + + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + + @override + String get combinedFeedPageTitle => 'Combined feed'; + + @override + String get mentionsPageTitle => 'Mentions'; + + @override + String get starredMessagesPageTitle => 'Starred messages'; + + @override + String get channelsPageTitle => 'Channels'; + + @override + String get mainMenuMyProfile => 'My profile'; + + @override + String get topicsButtonLabel => 'TOPICS'; + + @override + String get channelFeedButtonTooltip => 'Channel feed'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers others', + one: '1 other', + ); + return '$senderFullName to you and $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get subscriptionListNoChannels => 'No channels found'; + + @override + String get notifSelfUser => 'You'; + + @override + String get reactedEmojiSelfUser => 'You'; + + @override + String onePersonTyping(String typist) { + return '$typist is typing…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist and $otherTypist are typing…'; + } + + @override + String get manyPeopleTyping => 'Several people are typing…'; + + @override + String get wildcardMentionAll => 'all'; + + @override + String get wildcardMentionEveryone => 'everyone'; + + @override + String get wildcardMentionChannel => 'channel'; + + @override + String get wildcardMentionStream => 'stream'; + + @override + String get wildcardMentionTopic => 'topic'; + + @override + String get wildcardMentionChannelDescription => 'Notify channel'; + + @override + String get wildcardMentionStreamDescription => 'Notify stream'; + + @override + String get wildcardMentionAllDmDescription => 'Notify recipients'; + + @override + String get wildcardMentionTopicDescription => 'Notify topic'; + + @override + String get messageIsEditedLabel => 'EDITED'; + + @override + String get messageIsMovedLabel => 'MOVED'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + + @override + String get pollWidgetQuestionMissing => 'No question.'; + + @override + String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Failed to open notification'; + + @override + String get errorNotificationOpenAccountMissing => + 'The account associated with this notification no longer exists.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; +} + +/// The translations for Chinese, as used in China, using the Han script (`zh_Hans_CN`). +class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { + ZulipLocalizationsZhHansCn() : super('zh_Hans_CN'); + + @override + String get settingsPageTitle => '设置'; +} + +/// The translations for Chinese, as used in Taiwan, using the Han script (`zh_Hant_TW`). +class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { + ZulipLocalizationsZhHantTw() : super('zh_Hant_TW'); + + @override + String get settingsPageTitle => '設定'; +} From 245d9d990b3be339eae1ccb34c63b6354393fcb9 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 21 May 2025 14:54:41 -0700 Subject: [PATCH 094/290] msglist: Colorize channel icon in the app bar, following Figma The Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6089-28394&m=dev And while we're adding a test for it, also check that the chosen channel icon is the intended one. --- lib/widgets/message_list.dart | 13 ++++++++++-- test/widgets/message_list_test.dart | 31 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 3bc7d60363..2c24638f86 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -329,9 +329,18 @@ class MessageListAppBarTitle extends StatelessWidget { Widget _buildStreamRow(BuildContext context, { ZulipStream? stream, }) { + final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + // A null [Icon.icon] makes a blank space. - final icon = stream != null ? iconDataForStream(stream) : null; + IconData? icon; + Color? iconColor; + if (stream != null) { + icon = iconDataForStream(stream); + iconColor = colorSwatchFor(context, store.subscriptions[stream.streamId]) + .iconOnBarBackground; + } + return Row( mainAxisSize: MainAxisSize.min, // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. @@ -339,7 +348,7 @@ class MessageListAppBarTitle extends StatelessWidget { // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746 crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(size: 16, icon), + Icon(size: 16, color: iconColor, icon), const SizedBox(width: 4), Flexible(child: Text( stream?.name ?? zulipLocalizations.unknownChannelName)), diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 606a01f420..69aa693706 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -19,6 +19,7 @@ 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'; +import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; @@ -211,6 +212,36 @@ void main() { channel.name, eg.defaultRealmEmptyTopicDisplayName); }); + void testChannelIconInChannelRow(IconData expectedIcon, { + required bool isWebPublic, + required bool inviteOnly, + }) { + final description = 'channel icon in channel row; ' + 'web-public: $isWebPublic, invite-only: $inviteOnly'; + testWidgets(description, (tester) async { + final color = 0xff95a5fd; + + final channel = eg.stream(isWebPublic: isWebPublic, inviteOnly: inviteOnly); + final subscription = eg.subscription(channel, color: color); + + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + subscriptions: [subscription], + messages: [eg.streamMessage(stream: channel)]); + + final iconElement = tester.element(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.byIcon(expectedIcon))); + + check(Theme.brightnessOf(iconElement)).equals(Brightness.light); + check(iconElement.widget as Icon).color.equals(Color(0xff5972fc)); + }); + } + testChannelIconInChannelRow(ZulipIcons.globe, isWebPublic: true, inviteOnly: false); + testChannelIconInChannelRow(ZulipIcons.lock, isWebPublic: false, inviteOnly: true); + testChannelIconInChannelRow(ZulipIcons.hash_sign, isWebPublic: false, inviteOnly: false); + testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() From bb2055402fe00af1b8a38142ae5c7ebbda20b323 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 26 May 2025 19:45:20 -0400 Subject: [PATCH 095/290] msglist: Use store.senderDisplayName for sender row [chris: added tests] Co-authored-by: Chris Bobbe --- lib/widgets/message_list.dart | 2 +- test/widgets/message_list_test.dart | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 2c24638f86..aaf881905b 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1446,7 +1446,7 @@ class _SenderRow extends StatelessWidget { userId: message.senderId), const SizedBox(width: 8), Flexible( - child: Text(message.senderFullName, // TODO(#716): use `store.senderDisplayName` + child: Text(store.senderDisplayName(message), style: TextStyle( fontSize: 18, height: (22 / 18), diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 69aa693706..81cc384ef8 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1443,6 +1443,30 @@ void main() { }); group('MessageWithPossibleSender', () { + testWidgets('known user', (tester) async { + final user = eg.user(fullName: 'Old Name'); + await setupMessageListPage(tester, + messages: [eg.streamMessage(sender: user)], + users: [user]); + + check(find.widgetWithText(MessageWithPossibleSender, 'Old Name')).findsOne(); + + // If the user's name changes, the sender row should update. + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, fullName: 'New Name')); + await tester.pump(); + check(find.widgetWithText(MessageWithPossibleSender, 'New Name')).findsOne(); + }); + + testWidgets('unknown user', (tester) async { + final user = eg.user(fullName: 'Some User'); + await setupMessageListPage(tester, messages: [eg.streamMessage(sender: user)]); + check(store.getUser(user.userId)).isNull(); + + // The sender row should fall back to the name in the message. + check(find.widgetWithText(MessageWithPossibleSender, 'Some User')).findsOne(); + }); + testWidgets('Updates avatar on RealmUserUpdateEvent', (tester) async { addTearDown(testBinding.reset); From a2686955ffa36ea95f423a0fb5cfee2e0ada4f34 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 26 May 2025 19:46:57 -0400 Subject: [PATCH 096/290] msglist [nfc]: Make _SenderRow accept MessageBase [chris: removed unused import] Co-authored-by: Chris Bobbe --- lib/widgets/message_list.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index aaf881905b..e641a2519b 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1416,7 +1416,7 @@ final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); class _SenderRow extends StatelessWidget { const _SenderRow({required this.message, required this.showTimestamp}); - final Message message; + final MessageBase message; final bool showTimestamp; @override @@ -1446,7 +1446,9 @@ class _SenderRow extends StatelessWidget { userId: message.senderId), const SizedBox(width: 8), Flexible( - child: Text(store.senderDisplayName(message), + child: Text(message is Message + ? store.senderDisplayName(message as Message) + : store.userDisplayName(message.senderId), style: TextStyle( fontSize: 18, height: (22 / 18), From 33c97790b4276ed75378cd6cd424580ae8e2ddda Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 26 May 2025 17:51:09 -0400 Subject: [PATCH 097/290] compose [nfc]: Make confirmation dialog message flexible [chris: small formatting/naming changes] Co-authored-by: Chris Bobbe --- lib/widgets/compose_box.dart | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index d629e69029..320be5b155 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1849,11 +1849,13 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM @override void startEditInteraction(int messageId) async { - if (await _abortBecauseContentInputNotEmpty()) return; - if (!mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + + final abort = await _abortBecauseContentInputNotEmpty( + dialogMessage: zulipLocalizations.discardDraftConfirmationDialogMessage); + if (abort || !mounted) return; final store = PerAccountStoreWidget.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); switch (store.getEditMessageErrorStatus(messageId)) { case null: @@ -1878,12 +1880,14 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM /// If there's text in the compose box, give a confirmation dialog /// asking if it can be discarded and await the result. - Future _abortBecauseContentInputNotEmpty() async { + Future _abortBecauseContentInputNotEmpty({ + required String dialogMessage, + }) async { final zulipLocalizations = ZulipLocalizations.of(context); if (controller.content.textNormalized.isNotEmpty) { final dialog = showSuggestedActionDialog(context: context, title: zulipLocalizations.discardDraftConfirmationDialogTitle, - message: zulipLocalizations.discardDraftConfirmationDialogMessage, + message: dialogMessage, // TODO(#1032) "destructive" style for action button actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton); if (await dialog.result != true) return true; From efe86b48d9c64026f7b0dbd27e806d79051f5d5d Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 26 May 2025 18:30:24 -0400 Subject: [PATCH 098/290] compose test [nfc]: Move some edit message helpers out of group Helpers for starting an edit interaction and dealing with the confirmation dialog will be useful as we support retrieving messages not sent. --- test/widgets/compose_box_test.dart | 106 ++++++++++++++--------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 11467cea7d..b8ad524e85 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1412,6 +1412,51 @@ void main() { }); }); + /// Starts an edit interaction from the action sheet's 'Edit message' button. + /// + /// The fetch-raw-content request is prepared with [delay] (default 1s). + Future startEditInteractionFromActionSheet( + WidgetTester tester, { + required int messageId, + String originalRawContent = 'foo', + Duration delay = const Duration(seconds: 1), + bool fetchShouldSucceed = true, + }) async { + await tester.longPress(find.byWidgetPredicate((widget) => + widget is MessageWithPossibleSender && widget.item.message.id == messageId)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + final findEditButton = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.edit, skipOffstage: false)); + await tester.ensureVisible(findEditButton); + if (fetchShouldSucceed) { + connection.prepare(delay: delay, + json: GetMessageResult(message: eg.streamMessage(content: originalRawContent)).toJson()); + } else { + connection.prepare(apiException: eg.apiBadRequest(), delay: delay); + } + await tester.tap(findEditButton); + await tester.pump(); + await tester.pump(); + connection.takeRequests(); + } + + Future expectAndHandleDiscardConfirmation( + WidgetTester tester, { + required bool shouldContinue, + }) async { + final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Discard the message you’re writing?', + expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', + expectedActionButtonText: 'Discard'); + if (shouldContinue) { + await tester.tap(find.byWidget(actionButton)); + } else { + await tester.tap(find.byWidget(cancelButton)); + } + } + group('edit message', () { final channel = eg.stream(); final topic = 'topic'; @@ -1464,36 +1509,6 @@ void main() { check(connection.lastRequest).equals(lastRequest); } - /// Starts an interaction from the action sheet's 'Edit message' button. - /// - /// The fetch-raw-content request is prepared with [delay] (default 1s). - Future startInteractionFromActionSheet( - WidgetTester tester, { - required int messageId, - String originalRawContent = 'foo', - Duration delay = const Duration(seconds: 1), - bool fetchShouldSucceed = true, - }) async { - await tester.longPress(find.byWidgetPredicate((widget) => - widget is MessageWithPossibleSender && widget.item.message.id == messageId)); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); - final findEditButton = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.edit, skipOffstage: false)); - await tester.ensureVisible(findEditButton); - if (fetchShouldSucceed) { - connection.prepare(delay: delay, - json: GetMessageResult(message: eg.streamMessage(content: originalRawContent)).toJson()); - } else { - connection.prepare(apiException: eg.apiBadRequest(), delay: delay); - } - await tester.tap(findEditButton); - await tester.pump(); - await tester.pump(); - connection.takeRequests(); - } - /// Starts an interaction by tapping a failed edit in the message list. Future startInteractionFromRestoreFailedEdit( WidgetTester tester, { @@ -1501,7 +1516,7 @@ void main() { String originalRawContent = 'foo', String newContent = 'bar', }) async { - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: originalRawContent); await tester.pump(Duration(seconds: 1)); // raw-content request await enterContent(tester, newContent); @@ -1557,7 +1572,7 @@ void main() { final messageId = msgIdInNarrow(narrow); switch (start) { case _EditInteractionStart.actionSheet: - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await checkAwaitingRawMessageContent(tester); @@ -1608,21 +1623,6 @@ void main() { testSmoke(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); testSmoke(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); - Future expectAndHandleDiscardConfirmation( - WidgetTester tester, { - required bool shouldContinue, - }) async { - final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, - expectedTitle: 'Discard the message you’re writing?', - expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', - expectedActionButtonText: 'Discard'); - if (shouldContinue) { - await tester.tap(find.byWidget(actionButton)); - } else { - await tester.tap(find.byWidget(cancelButton)); - } - } - // Test the "Discard…?" confirmation dialog when you tap "Edit message" in // the action sheet but there's text in the compose box for a new message. void testInterruptComposingFromActionSheet({required Narrow narrow}) { @@ -1637,7 +1637,7 @@ void main() { await enterContent(tester, 'composing new message'); // Expect confirmation dialog; tap Cancel - await startInteractionFromActionSheet(tester, messageId: messageId); + await startEditInteractionFromActionSheet(tester, messageId: messageId); await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); check(connection.takeRequests()).isEmpty(); // fetch-raw-content request wasn't actually sent; @@ -1651,7 +1651,7 @@ void main() { checkContentInputValue(tester, 'composing new message…'); // Try again, but this time tap Discard and expect to enter an edit session - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); await tester.pump(); @@ -1685,7 +1685,7 @@ void main() { final messageId = msgIdInNarrow(narrow); await prepareEditMessage(tester, narrow: narrow); - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await tester.pump(Duration(seconds: 1)); // raw-content request await enterContent(tester, 'bar'); @@ -1742,7 +1742,7 @@ void main() { checkNotInEditingMode(tester, narrow: narrow); final messageId = msgIdInNarrow(narrow); - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo', fetchShouldSucceed: false); @@ -1790,7 +1790,7 @@ void main() { final messageId = msgIdInNarrow(narrow); switch (start) { case _EditInteractionStart.actionSheet: - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, delay: Duration(seconds: 5)); await checkAwaitingRawMessageContent(tester); await tester.pump(duringFetchRawContentRequest! @@ -1809,7 +1809,7 @@ void main() { // We've canceled the previous edit session, so we should be able to // do a new edit-message session… - await startInteractionFromActionSheet(tester, + await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); await checkAwaitingRawMessageContent(tester); await tester.pump(Duration(seconds: 1)); // fetch-raw-content request From 47680e91a507b79666956ec461d87b36a2315988 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 26 May 2025 17:55:54 -0400 Subject: [PATCH 099/290] compose [nfc]: Update string to mention "edit" in name and description This migrates the Polish and Russian translations (the only existing non-en locales to translate this) to use the new string as well. --- assets/l10n/app_en.arb | 6 +++--- assets/l10n/app_pl.arb | 6 +++--- assets/l10n/app_ru.arb | 4 ++-- lib/generated/l10n/zulip_localizations.dart | 4 ++-- .../l10n/zulip_localizations_ar.dart | 2 +- .../l10n/zulip_localizations_de.dart | 2 +- .../l10n/zulip_localizations_en.dart | 2 +- .../l10n/zulip_localizations_ja.dart | 2 +- .../l10n/zulip_localizations_nb.dart | 2 +- .../l10n/zulip_localizations_pl.dart | 2 +- .../l10n/zulip_localizations_ru.dart | 2 +- .../l10n/zulip_localizations_sk.dart | 2 +- .../l10n/zulip_localizations_uk.dart | 2 +- .../l10n/zulip_localizations_zh.dart | 2 +- lib/widgets/compose_box.dart | 2 +- test/widgets/compose_box_test.dart | 19 ++++++++++++++----- 16 files changed, 35 insertions(+), 26 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 91206fabc6..55542171cf 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -377,9 +377,9 @@ "@discardDraftConfirmationDialogTitle": { "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." }, - "discardDraftConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", - "@discardDraftConfirmationDialogMessage": { - "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + "discardDraftForEditConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." }, "discardDraftConfirmationDialogConfirmButton": "Discard", "@discardDraftConfirmationDialogConfirmButton": { diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 468e2136b2..d02f55a4f5 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1049,9 +1049,9 @@ "@discardDraftConfirmationDialogTitle": { "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." }, - "discardDraftConfirmationDialogMessage": "Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.", - "@discardDraftConfirmationDialogMessage": { - "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + "discardDraftForEditConfirmationDialogMessage": "Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." }, "discardDraftConfirmationDialogConfirmButton": "Odrzuć", "@discardDraftConfirmationDialogConfirmButton": { diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index bf36f0c900..3e5ac1ec3c 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1069,8 +1069,8 @@ "@editAlreadyInProgressMessage": { "description": "Error message when a message edit cannot be saved because there is another edit already in progress." }, - "discardDraftConfirmationDialogMessage": "При изменении сообщения текст из поля для редактирования удаляется.", - "@discardDraftConfirmationDialogMessage": { + "discardDraftForEditConfirmationDialogMessage": "При изменении сообщения текст из поля для редактирования удаляется.", + "@discardDraftForEditConfirmationDialogMessage": { "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index de0368bb6d..6f8b6315b4 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -643,11 +643,11 @@ abstract class ZulipLocalizations { /// **'Discard the message you’re writing?'** String get discardDraftConfirmationDialogTitle; - /// Message for a confirmation dialog for discarding message text that was typed into the compose box. + /// Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message. /// /// In en, this message translates to: /// **'When you edit a message, the content that was previously in the compose box is discarded.'** - String get discardDraftConfirmationDialogMessage; + String get discardDraftForEditConfirmationDialogMessage; /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index febbb2c585..57f236bd03 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -318,7 +318,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; @override diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index a9188773c0..e0ac848b7d 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -318,7 +318,7 @@ class ZulipLocalizationsDe extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; @override diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 73a4212ee3..8a399bdbaf 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -318,7 +318,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; @override diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index b614f71cbb..a33b115bb7 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -318,7 +318,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; @override diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 4929eae453..3fabc54006 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -318,7 +318,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; @override diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 3af0e94f46..dae93fa495 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -325,7 +325,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Czy chcesz przerwać szykowanie wpisu?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; @override diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 72a87e6c32..9dc9fa3b0b 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -326,7 +326,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'Отказаться от написанного сообщения?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'При изменении сообщения текст из поля для редактирования удаляется.'; @override diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 30cd4c89f8..5436670934 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -318,7 +318,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; @override diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index a77d28aeff..7deabdf03d 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -327,7 +327,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; @override diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 59c3a57129..13d65a499b 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -318,7 +318,7 @@ class ZulipLocalizationsZh extends ZulipLocalizations { 'Discard the message you’re writing?'; @override - String get discardDraftConfirmationDialogMessage => + String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; @override diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 320be5b155..2f47f05f44 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1852,7 +1852,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM final zulipLocalizations = ZulipLocalizations.of(context); final abort = await _abortBecauseContentInputNotEmpty( - dialogMessage: zulipLocalizations.discardDraftConfirmationDialogMessage); + dialogMessage: zulipLocalizations.discardDraftForEditConfirmationDialogMessage); if (abort || !mounted) return; final store = PerAccountStoreWidget.of(context); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index b8ad524e85..b4de306aa3 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1444,11 +1444,12 @@ void main() { Future expectAndHandleDiscardConfirmation( WidgetTester tester, { + required String expectedMessage, required bool shouldContinue, }) async { final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, expectedTitle: 'Discard the message you’re writing?', - expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', + expectedMessage: expectedMessage, expectedActionButtonText: 'Discard'); if (shouldContinue) { await tester.tap(find.byWidget(actionButton)); @@ -1623,6 +1624,14 @@ void main() { testSmoke(narrow: topicNarrow, start: _EditInteractionStart.restoreFailedEdit); testSmoke(narrow: dmNarrow, start: _EditInteractionStart.restoreFailedEdit); + Future expectAndHandleDiscardForEditConfirmation(WidgetTester tester, { + required bool shouldContinue, + }) { + return expectAndHandleDiscardConfirmation(tester, + expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', + shouldContinue: shouldContinue); + } + // Test the "Discard…?" confirmation dialog when you tap "Edit message" in // the action sheet but there's text in the compose box for a new message. void testInterruptComposingFromActionSheet({required Narrow narrow}) { @@ -1638,7 +1647,7 @@ void main() { // Expect confirmation dialog; tap Cancel await startEditInteractionFromActionSheet(tester, messageId: messageId); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); check(connection.takeRequests()).isEmpty(); // fetch-raw-content request wasn't actually sent; // take back its prepared response @@ -1653,7 +1662,7 @@ void main() { // Try again, but this time tap Discard and expect to enter an edit session await startEditInteractionFromActionSheet(tester, messageId: messageId, originalRawContent: 'foo'); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); await tester.pump(); await checkAwaitingRawMessageContent(tester); await tester.pump(Duration(seconds: 1)); // fetch-raw-content request @@ -1702,7 +1711,7 @@ void main() { // Expect confirmation dialog; tap Cancel await tester.tap(find.text('EDIT NOT SAVED')); await tester.pump(); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: false); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: false); checkNotInEditingMode(tester, narrow: narrow, expectedContentText: 'composing new message'); @@ -1712,7 +1721,7 @@ void main() { // Try again, but this time tap Discard and expect to enter edit session await tester.tap(find.text('EDIT NOT SAVED')); await tester.pump(); - await expectAndHandleDiscardConfirmation(tester, shouldContinue: true); + await expectAndHandleDiscardForEditConfirmation(tester, shouldContinue: true); await tester.pump(); checkContentInputValue(tester, 'bar'); await enterContent(tester, 'baz'); From 145e99f6a692606817d5a7ef9425d60403c34d3f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 29 May 2025 16:40:52 -0700 Subject: [PATCH 100/290] msglist [nfc]: Distribute a 4px bottom padding down to conditional cases To prepare for adjusting one of the cases from 4px to 2px: https://github.com/zulip/zulip-flutter/pull/1535#discussion_r2114820185 --- lib/widgets/message_list.dart | 70 +++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index e641a2519b..b82830e6cc 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1537,7 +1537,7 @@ class MessageWithPossibleSender extends StatelessWidget { behavior: HitTestBehavior.translucent, onLongPress: () => showMessageActionSheet(context: context, message: message), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.only(top: 4), child: Column(children: [ if (item.showSender) _SenderRow(message: message, showTimestamp: true), @@ -1555,14 +1555,18 @@ class MessageWithPossibleSender extends StatelessWidget { if (editMessageErrorStatus != null) _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) else if (editStateText != null) - Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing( - context, 0.05, baseFontSize: 12))), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12)))) + else + Padding(padding: const EdgeInsets.only(bottom: 4)) ])), SizedBox(width: 16, child: star), @@ -1593,30 +1597,34 @@ class _EditMessageStatusRow extends StatelessWidget { return switch (status) { // TODO parse markdown and show new content as local echo? - false => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 1.5, - children: [ - Text( + false => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 1.5, + children: [ + Text( + style: baseTextStyle + .copyWith(color: designVariables.btnLabelAttLowIntInfo), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditLabel), + // TODO instead place within bottom outer padding: + // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2087576108 + LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withValues(alpha: 0.5), + backgroundColor: designVariables.foreground.withValues(alpha: 0.2), + ), + ])), + true => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _RestoreEditMessageGestureDetector( + messageId: messageId, + child: Text( style: baseTextStyle - .copyWith(color: designVariables.btnLabelAttLowIntInfo), + .copyWith(color: designVariables.btnLabelAttLowIntDanger), textAlign: TextAlign.end, - zulipLocalizations.savingMessageEditLabel), - // TODO instead place within bottom outer padding: - // https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2087576108 - LinearProgressIndicator( - minHeight: 2, - color: designVariables.foreground.withValues(alpha: 0.5), - backgroundColor: designVariables.foreground.withValues(alpha: 0.2), - ), - ]), - true => _RestoreEditMessageGestureDetector( - messageId: messageId, - child: Text( - style: baseTextStyle - .copyWith(color: designVariables.btnLabelAttLowIntDanger), - textAlign: TextAlign.end, - zulipLocalizations.savingMessageEditFailedLabel)), + zulipLocalizations.savingMessageEditFailedLabel))), }; } } From 5636e7242f6e127a861013a78b5ed25816f0d5ed Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 26 May 2025 20:14:45 -0400 Subject: [PATCH 101/290] msglist: Put edit-message progress bar in top half of 4px bottom padding This is where the progress bar for outbox messages will go, so this is for consistency with that. Discussion: https://github.com/zulip/zulip-flutter/pull/1453#discussion_r2107935179 [chris: fixed to maintain 4px bottom padding in the common case where the progress bar is absent] Co-authored-by: Chris Bobbe --- 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 b82830e6cc..17422f8e95 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1598,7 +1598,7 @@ class _EditMessageStatusRow extends StatelessWidget { return switch (status) { // TODO parse markdown and show new content as local echo? false => Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.only(bottom: 2), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 1.5, From ca3ef63c468b5e5bec8077209ba1722c0c360c39 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 27 Mar 2025 20:39:21 +0530 Subject: [PATCH 102/290] notif: Fix error message when account not found in store [greg: cherry-picked from #1379] --- assets/l10n/app_en.arb | 6 +++--- assets/l10n/app_pl.arb | 4 ---- assets/l10n/app_ru.arb | 4 ---- lib/generated/l10n/zulip_localizations.dart | 6 +++--- lib/generated/l10n/zulip_localizations_ar.dart | 4 ++-- lib/generated/l10n/zulip_localizations_de.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/generated/l10n/zulip_localizations_uk.dart | 4 ++-- lib/generated/l10n/zulip_localizations_zh.dart | 4 ++-- lib/notifications/display.dart | 2 +- test/notifications/display_test.dart | 4 ++-- 16 files changed, 29 insertions(+), 37 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 55542171cf..cd10f9feb4 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -919,9 +919,9 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" + "errorNotificationOpenAccountNotFound": "The account associated with this notification could not be found.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" }, "errorReactionAddingFailedTitle": "Adding reaction failed", "@errorReactionAddingFailedTitle": { diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index d02f55a4f5..4c6dc378ea 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -557,10 +557,6 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "Konto związane z tym powiadomieniem już nie istnieje.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "aboutPageOpenSourceLicenses": "Licencje otwartego źródła", "@aboutPageOpenSourceLicenses": { "description": "Item title in About Zulip page to navigate to Licenses page" diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 3e5ac1ec3c..c5c29419c6 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -287,10 +287,6 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "Учетной записи, связанной с этим оповещением, больше нет.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "switchAccountButton": "Сменить учетную запись", "@switchAccountButton": { "description": "Label for main-menu button leading to the choose-account page." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 6f8b6315b4..a1dc9159b9 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1367,11 +1367,11 @@ abstract class ZulipLocalizations { /// **'Failed to open notification'** String get errorNotificationOpenTitle; - /// Error message when the account associated with the notification is not found + /// Error message when the account associated with the notification could not be found /// /// In en, this message translates to: - /// **'The account associated with this notification no longer exists.'** - String get errorNotificationOpenAccountMissing; + /// **'The account associated with this notification could not be found.'** + String get errorNotificationOpenAccountNotFound; /// Error title when adding a message reaction fails /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 57f236bd03..d3b3c7b82c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -747,8 +747,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index e0ac848b7d..4756d909e6 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -747,8 +747,8 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 8a399bdbaf..288bc1e922 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -747,8 +747,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index a33b115bb7..28cbe6b07d 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -747,8 +747,8 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3fabc54006..e4da3fd777 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -747,8 +747,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index dae93fa495..935ac649d9 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -757,8 +757,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Otwieranie powiadomienia bez powodzenia'; @override - String get errorNotificationOpenAccountMissing => - 'Konto związane z tym powiadomieniem już nie istnieje.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Dodanie reakcji bez powodzenia'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 9dc9fa3b0b..87b1df0b42 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -761,8 +761,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не удалось открыть оповещения'; @override - String get errorNotificationOpenAccountMissing => - 'Учетной записи, связанной с этим оповещением, больше нет.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Не удалось добавить реакцию'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 5436670934..0509f18970 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -749,8 +749,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Nepodarilo sa otvoriť oznámenie'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Nepodarilo sa pridať reakciu'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 7deabdf03d..18c58febdb 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -761,8 +761,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не вдалося відкрити сповіщення'; @override - String get errorNotificationOpenAccountMissing => - 'Обліковий запис, пов’язаний із цим сповіщенням, більше не існує.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Не вдалося додати реакцію'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 13d65a499b..d4232a55a0 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -747,8 +747,8 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => - 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 00a5f6fda5..081a2cf633 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -507,7 +507,7 @@ class NotificationDisplayManager { final zulipLocalizations = ZulipLocalizations.of(context); showErrorDialog(context: context, title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); + message: zulipLocalizations.errorNotificationOpenAccountNotFound); return null; } diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index ccba7e24cc..ffb5d345d8 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1137,7 +1137,7 @@ void main() { check(pushedRoutes.single).isA>(); await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); }); testWidgets('mismatching account', (tester) async { @@ -1149,7 +1149,7 @@ void main() { check(pushedRoutes.single).isA>(); await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); }); testWidgets('find account among several', (tester) async { From bb1ca8868489b439d89ec729f0764a6cf709370e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 29 May 2025 17:15:07 -0700 Subject: [PATCH 103/290] l10n: Add translatable strings from v0.0.30 release This will enable our translation contributors to start translating these ahead of the upcoming launch, even though the features that use them aren't yet merged to main. --- assets/l10n/app_en.arb | 52 +++++++++++++ lib/generated/l10n/zulip_localizations.dart | 78 +++++++++++++++++++ .../l10n/zulip_localizations_ar.dart | 40 ++++++++++ .../l10n/zulip_localizations_de.dart | 40 ++++++++++ .../l10n/zulip_localizations_en.dart | 40 ++++++++++ .../l10n/zulip_localizations_ja.dart | 40 ++++++++++ .../l10n/zulip_localizations_nb.dart | 40 ++++++++++ .../l10n/zulip_localizations_pl.dart | 40 ++++++++++ .../l10n/zulip_localizations_ru.dart | 40 ++++++++++ .../l10n/zulip_localizations_sk.dart | 40 ++++++++++ .../l10n/zulip_localizations_uk.dart | 40 ++++++++++ .../l10n/zulip_localizations_zh.dart | 40 ++++++++++ 12 files changed, 530 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index cd10f9feb4..aa01eda043 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -132,6 +132,10 @@ "@actionSheetOptionMarkAsUnread": { "description": "Label for mark as unread button on action sheet." }, + "actionSheetOptionHideMutedMessage": "Hide muted message again", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, "actionSheetOptionShare": "Share", "@actionSheetOptionShare": { "description": "Label for share button on action sheet." @@ -381,6 +385,10 @@ "@discardDraftForEditConfirmationDialogMessage": { "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." }, + "discardDraftForMessageNotSentConfirmationDialogMessage": "When you restore a message not sent, the content that was previously in the compose box is discarded.", + "@discardDraftForMessageNotSentConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." + }, "discardDraftConfirmationDialogConfirmButton": "Discard", "@discardDraftConfirmationDialogConfirmButton": { "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." @@ -401,6 +409,34 @@ "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." }, + "newDmSheetBackButtonLabel": "Back", + "@newDmSheetBackButtonLabel": { + "description": "Label for the back button in the new DM sheet, allowing the user to return to the previous screen." + }, + "newDmSheetNextButtonLabel": "Next", + "@newDmSheetNextButtonLabel": { + "description": "Label for the front button in the new DM sheet, if applicable, for navigation or action." + }, + "newDmSheetScreenTitle": "New DM", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "New DM", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintEmpty": "Add one or more users", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetSearchHintSomeSelected": "Add another user…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected" + }, + "newDmSheetNoUsersFound": "No users found", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, "composeBoxDmContentHint": "Message @{user}", "@composeBoxDmContentHint": { "description": "Hint text for content input when sending a message to one other person.", @@ -872,6 +908,10 @@ "@messageIsMovedLabel": { "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, + "messageNotSentLabel": "MESSAGE NOT SENT", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, "pollVoterNames": "({voterNames})", "@pollVoterNames": { "description": "The list of people who voted for a poll option, wrapped in parentheses.", @@ -943,6 +983,18 @@ "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." }, + "mutedSender": "Muted sender", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "revealButtonLabel": "Reveal message for muted sender", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Muted user", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { "description": "Tooltip for button to scroll to bottom." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index a1dc9159b9..306596044b 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -323,6 +323,12 @@ abstract class ZulipLocalizations { /// **'Mark as unread from here'** String get actionSheetOptionMarkAsUnread; + /// Label for hide muted message again button on action sheet. + /// + /// In en, this message translates to: + /// **'Hide muted message again'** + String get actionSheetOptionHideMutedMessage; + /// Label for share button on action sheet. /// /// In en, this message translates to: @@ -649,6 +655,12 @@ abstract class ZulipLocalizations { /// **'When you edit a message, the content that was previously in the compose box is discarded.'** String get discardDraftForEditConfirmationDialogMessage; + /// Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'When you restore a message not sent, the content that was previously in the compose box is discarded.'** + String get discardDraftForMessageNotSentConfirmationDialogMessage; + /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. /// /// In en, this message translates to: @@ -679,6 +691,48 @@ abstract class ZulipLocalizations { /// **'Type a message'** String get composeBoxGenericContentHint; + /// Label for the back button in the new DM sheet, allowing the user to return to the previous screen. + /// + /// In en, this message translates to: + /// **'Back'** + String get newDmSheetBackButtonLabel; + + /// Label for the front button in the new DM sheet, if applicable, for navigation or action. + /// + /// In en, this message translates to: + /// **'Next'** + String get newDmSheetNextButtonLabel; + + /// Title displayed at the top of the new DM screen. + /// + /// In en, this message translates to: + /// **'New DM'** + String get newDmSheetScreenTitle; + + /// Label for the floating action button (FAB) that opens the new DM sheet. + /// + /// In en, this message translates to: + /// **'New DM'** + String get newDmFabButtonLabel; + + /// Hint text for the search bar when no users are selected + /// + /// In en, this message translates to: + /// **'Add one or more users'** + String get newDmSheetSearchHintEmpty; + + /// Hint text for the search bar when at least one user is selected + /// + /// In en, this message translates to: + /// **'Add another user…'** + String get newDmSheetSearchHintSomeSelected; + + /// Message shown in the new DM sheet when no users match the search. + /// + /// In en, this message translates to: + /// **'No users found'** + String get newDmSheetNoUsersFound; + /// Hint text for content input when sending a message to one other person. /// /// In en, this message translates to: @@ -1301,6 +1355,12 @@ abstract class ZulipLocalizations { /// **'MOVED'** String get messageIsMovedLabel; + /// Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'MESSAGE NOT SENT'** + String get messageNotSentLabel; + /// The list of people who voted for a poll option, wrapped in parentheses. /// /// In en, this message translates to: @@ -1403,6 +1463,24 @@ abstract class ZulipLocalizations { /// **'No earlier messages'** String get noEarlierMessages; + /// Name for a muted user to display in message list. + /// + /// In en, this message translates to: + /// **'Muted sender'** + String get mutedSender; + + /// Label for the button revealing hidden message from a muted sender in message list. + /// + /// In en, this message translates to: + /// **'Reveal message for muted sender'** + String get revealButtonLabel; + + /// Name for a muted user to display all over the app. + /// + /// In en, this message translates to: + /// **'Muted user'** + String get mutedUser; + /// Tooltip for button to scroll to bottom. /// /// 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 d3b3c7b82c..92b2ce681c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -113,6 +113,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -321,6 +324,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -336,6 +343,27 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -710,6 +738,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -765,6 +796,15 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 4756d909e6..54faf4fde4 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -113,6 +113,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -321,6 +324,10 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -336,6 +343,27 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -710,6 +738,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -765,6 +796,15 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 288bc1e922..dacb23923a 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -113,6 +113,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -321,6 +324,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -336,6 +343,27 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -710,6 +738,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -765,6 +796,15 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 28cbe6b07d..9d7e3ce291 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -113,6 +113,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -321,6 +324,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -336,6 +343,27 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -710,6 +738,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -765,6 +796,15 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index e4da3fd777..92f9e33706 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -113,6 +113,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -321,6 +324,10 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -336,6 +343,27 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -710,6 +738,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -765,6 +796,15 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 935ac649d9..2657910637 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -118,6 +118,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Odtąd oznacz jako nieprzeczytane'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Udostępnij'; @@ -328,6 +331,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; @@ -343,6 +350,27 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Wpisz wiadomość'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Napisz do @$user'; @@ -719,6 +747,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRZENIESIONO'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -776,6 +807,15 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get noEarlierMessages => 'Brak historii'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Przewiń do dołu'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 87b1df0b42..bd4ee4423c 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -118,6 +118,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Отметить как непрочитанные начиная отсюда'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Поделиться'; @@ -329,6 +332,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'При изменении сообщения текст из поля для редактирования удаляется.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; @@ -344,6 +351,27 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Ввести сообщение'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Сообщение для @$user'; @@ -723,6 +751,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -779,6 +810,15 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get noEarlierMessages => 'Предшествующих сообщений нет'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Пролистать вниз'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 0509f18970..93103de344 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -114,6 +114,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Označiť ako neprečítané od tejto správy'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Zdielať'; @@ -321,6 +324,10 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -336,6 +343,27 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -712,6 +740,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRESUNUTÉ'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -767,6 +798,15 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 18c58febdb..9f49e2df4d 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -118,6 +118,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Позначити як непрочитане звідси'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Поширити'; @@ -330,6 +333,10 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -345,6 +352,27 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Ввести повідомлення'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Повідомлення @$user'; @@ -722,6 +750,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМІЩЕНО'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -779,6 +810,15 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get noEarlierMessages => 'Немає попередніх повідомлень'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Прокрутити вниз'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index d4232a55a0..8b3760c36d 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -113,6 +113,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -321,6 +324,10 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get discardDraftForEditConfirmationDialogMessage => 'When you edit a message, the content that was previously in the compose box is discarded.'; + @override + String get discardDraftForMessageNotSentConfirmationDialogMessage => + 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; @@ -336,6 +343,27 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get composeBoxGenericContentHint => 'Type a message'; + @override + String get newDmSheetBackButtonLabel => 'Back'; + + @override + String get newDmSheetNextButtonLabel => 'Next'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + @override String composeBoxDmContentHint(String user) { return 'Message @$user'; @@ -710,6 +738,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; @@ -765,6 +796,15 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; From a4b5abb6d775423926d92573e9873918c708ba4f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 29 May 2025 19:40:40 -0700 Subject: [PATCH 104/290] msglist [nfc]: Add a TODO(#1518) for restore-failed-edit --- lib/widgets/message_list.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 17422f8e95..e25445e514 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1644,6 +1644,7 @@ class _RestoreEditMessageGestureDetector extends StatelessWidget { behavior: HitTestBehavior.opaque, onTap: () { final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + // TODO(#1518) allow restore-edit-message from any message-list page if (composeBoxState == null) return; composeBoxState.startEditInteraction(messageId); }, From dbc3488695af8429323defeb8c8a16d549ed1724 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 13:17:51 -0700 Subject: [PATCH 105/290] home test [nfc]: Move testNavObserver out to `main` This will be useful for some other tests. --- test/widgets/home_test.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 3c8db1dfcf..ff9f1c61af 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -33,11 +33,14 @@ void main () { late PerAccountStore store; late FakeApiConnection connection; + late List> pushedRoutes; - Future prepare(WidgetTester tester, { - NavigatorObserver? navigatorObserver, - }) async { + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + pushedRoutes = []; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; @@ -45,7 +48,7 @@ void main () { await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, - navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], + navigatorObservers: [testNavObserver], child: const HomePage())); await tester.pump(); } @@ -118,10 +121,7 @@ void main () { }); testWidgets('combined feed', (tester) async { - final pushedRoutes = >[]; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - await prepare(tester, navigatorObserver: testNavObserver); + await prepare(tester); pushedRoutes.clear(); connection.prepare(json: eg.newestGetMessagesResult( From e094fdc3aa21803abb1dc4bb1ad75d5bf1deb585 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 13:59:32 -0700 Subject: [PATCH 106/290] home test: Remove an unnecessary `tester.pump` for a route animation This test doesn't need to check the widget tree after waiting for the route animation to complete. The navigator observer is alerted when the navigation action is dispatched, not when its animation completes, so it's fine to check pushedRoutes before waiting through the animation. (It still needs a Duration.zero wait so that a FakeApiConnection timer isn't still pending at the end of the test.) --- test/widgets/home_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index ff9f1c61af..09c4c42b0d 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -128,10 +128,10 @@ void main () { foundOldest: true, messages: []).toJson()); await tester.tap(find.byIcon(ZulipIcons.message_feed)); await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); check(pushedRoutes).single.isA().page .isA() .initNarrow.equals(const CombinedFeedNarrow()); + await tester.pump(Duration.zero); // message-list fetch }); }); From 177ad1c517dae422ebef5dab2678b6671afb32ce Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 15:59:22 -0700 Subject: [PATCH 107/290] home test [nfc]: Move some `find.descendant`s This duplicates the `find.descendant`s in `tester.tap` callsites, but that's temporary; we'll deduplicate with a new helper function, coming up. --- test/widgets/home_test.dart | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 09c4c42b0d..036d028f02 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -138,15 +138,9 @@ void main () { group('menu', () { final designVariables = DesignVariables.light; - final inboxMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.inbox)); - final channelsMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.hash_italic)); - final combinedFeedMenuIconFinder = find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(ZulipIcons.message_feed)); + final inboxMenuIconFinder = find.byIcon(ZulipIcons.inbox); + final channelsMenuIconFinder = find.byIcon(ZulipIcons.hash_italic); + final combinedFeedMenuIconFinder = find.byIcon(ZulipIcons.message_feed); Future tapOpenMenu(WidgetTester tester) async { await tester.tap(find.byIcon(ZulipIcons.menu)); @@ -156,12 +150,18 @@ void main () { } void checkIconSelected(WidgetTester tester, Finder finder) { - check(tester.widget(finder)).isA().color.isNotNull() + final widget = tester.widget(find.descendant( + of: find.byType(BottomSheet), + matching: finder)); + check(widget).isA().color.isNotNull() .isSameColorAs(designVariables.iconSelected); } void checkIconNotSelected(WidgetTester tester, Finder finder) { - check(tester.widget(finder)).isA().color.isNotNull() + final widget = tester.widget(find.descendant( + of: find.byType(BottomSheet), + matching: finder)); + check(widget).isA().color.isNotNull() .isSameColorAs(designVariables.icon); } @@ -192,7 +192,9 @@ void main () { check(find.byType(InboxPageBody)).findsOne(); check(find.byType(SubscriptionListPageBody)).findsNothing(); - await tester.tap(channelsMenuIconFinder); + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: channelsMenuIconFinder)); await tester.pump(Duration.zero); // tap the button await tester.pump(const Duration(milliseconds: 250)); // wait for animation check(find.byType(BottomSheet)).findsNothing(); @@ -208,7 +210,9 @@ void main () { await prepare(tester); await tapOpenMenu(tester); - await tester.tap(channelsMenuIconFinder); + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: channelsMenuIconFinder)); await tester.pump(Duration.zero); // tap the button await tester.pump(const Duration(milliseconds: 250)); // wait for animation check(find.byType(BottomSheet)).findsNothing(); @@ -237,7 +241,9 @@ void main () { connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [eg.streamMessage()]).toJson()); - await tester.tap(combinedFeedMenuIconFinder); + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: combinedFeedMenuIconFinder)); await tester.pump(Duration.zero); // tap the button await tester.pump(const Duration(milliseconds: 250)); // wait for animation From 22e05024ffe5ff659e6359146f01e6c106f870b9 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 17:55:22 -0700 Subject: [PATCH 108/290] home test: Make tapOpenMenu stronger; rename with "AndAwait" to clarify This is robust to changes in the entrance-animation duration, and it checks the state more thoroughly. --- test/test_navigation.dart | 6 +++++ test/widgets/home_test.dart | 50 ++++++++++++++++++++++++++----------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/test/test_navigation.dart b/test/test_navigation.dart index b5065d684c..35da8af6b0 100644 --- a/test/test_navigation.dart +++ b/test/test_navigation.dart @@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart'; /// A trivial observer for testing the navigator. class TestNavigatorObserver extends NavigatorObserver { + void Function(Route topRoute, Route? previousTopRoute)? onChangedTop; void Function(Route route, Route? previousRoute)? onPushed; void Function(Route route, Route? previousRoute)? onPopped; void Function(Route route, Route? previousRoute)? onRemoved; @@ -13,6 +14,11 @@ class TestNavigatorObserver extends NavigatorObserver { void Function(Route route, Route? previousRoute)? onStartUserGesture; void Function()? onStopUserGesture; + @override + void didChangeTop(Route topRoute, Route? previousTopRoute) { + onChangedTop?.call(topRoute, previousTopRoute); + } + @override void didPush(Route route, Route? previousRoute) { onPushed?.call(route, previousRoute); diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 036d028f02..8f7002a001 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -33,13 +33,22 @@ void main () { late PerAccountStore store; late FakeApiConnection connection; + + late Route? topRoute; + late Route? previousTopRoute; late List> pushedRoutes; final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + ..onChangedTop = ((current, previous) { + topRoute = current; + previousTopRoute = previous; + }) + ..onPushed = ((route, prevRoute) => pushedRoutes.add(route)); Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; pushedRoutes = []; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); @@ -142,10 +151,20 @@ void main () { final channelsMenuIconFinder = find.byIcon(ZulipIcons.hash_italic); final combinedFeedMenuIconFinder = find.byIcon(ZulipIcons.message_feed); - Future tapOpenMenu(WidgetTester tester) async { + Future tapOpenMenuAndAwait(WidgetTester tester) async { + final topRouteBeforePress = topRoute; await tester.tap(find.byIcon(ZulipIcons.menu)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tester.pump(); + final topRouteAfterPress = topRoute; + check(topRouteAfterPress).isA>(); + await tester.pump((topRouteAfterPress as ModalBottomSheetRoute).transitionDuration); + + // This was the only change during the interaction. + check(topRouteBeforePress).identicalTo(previousTopRoute); + + // We got to the sheet by pushing, not popping or something else. + check(pushedRoutes.last).identicalTo(topRouteAfterPress); + check(find.byType(BottomSheet)).findsOne(); } @@ -168,7 +187,7 @@ void main () { testWidgets('navigation states reflect on navigation bar menu buttons', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); await tester.tap(find.text('Cancel')); @@ -178,7 +197,7 @@ void main () { await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconNotSelected(tester, inboxMenuIconFinder); checkIconSelected(tester, channelsMenuIconFinder); }); @@ -186,7 +205,7 @@ void main () { testWidgets('navigation bar menu buttons control navigation states', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); check(find.byType(InboxPageBody)).findsOne(); @@ -201,14 +220,14 @@ void main () { check(find.byType(InboxPageBody)).findsNothing(); check(find.byType(SubscriptionListPageBody)).findsOne(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); checkIconNotSelected(tester, inboxMenuIconFinder); checkIconSelected(tester, channelsMenuIconFinder); }); testWidgets('navigation bar menu buttons dismiss the menu', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); await tester.tap(find.descendant( of: find.byType(BottomSheet), @@ -220,7 +239,7 @@ void main () { testWidgets('cancel button dismisses the menu', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); await tester.tap(find.text('Cancel')); await tester.pump(Duration.zero); // tap the button @@ -230,14 +249,17 @@ void main () { testWidgets('menu buttons dismiss the menu', (tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; + pushedRoutes = []; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await tester.pumpWidget(const ZulipApp()); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final connection = store.connection as FakeApiConnection; await tester.pump(); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [eg.streamMessage()]).toJson()); @@ -255,7 +277,7 @@ void main () { testWidgets('_MyProfileButton', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); await tester.tap(find.text('My profile')); await tester.pump(Duration.zero); // tap the button @@ -266,7 +288,7 @@ void main () { testWidgets('_AboutZulipButton', (tester) async { await prepare(tester); - await tapOpenMenu(tester); + await tapOpenMenuAndAwait(tester); await tester.tap(find.byIcon(ZulipIcons.info)); await tester.pump(Duration.zero); // tap the button From 67842c075d399cd15d3db8fe1e273a2242e433a0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 13:56:53 -0700 Subject: [PATCH 109/290] home test: Add tapButtonAndAwaitTransition As in the previous commit, this removes some more hard-coding of route-animation durations. --- test/widgets/home_test.dart | 85 ++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 8f7002a001..a1cb6667a1 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -37,19 +37,22 @@ void main () { late Route? topRoute; late Route? previousTopRoute; late List> pushedRoutes; + late Route? lastPoppedRoute; final testNavObserver = TestNavigatorObserver() ..onChangedTop = ((current, previous) { topRoute = current; previousTopRoute = previous; }) - ..onPushed = ((route, prevRoute) => pushedRoutes.add(route)); + ..onPushed = ((route, prevRoute) => pushedRoutes.add(route)) + ..onPopped = ((route, prevRoute) => lastPoppedRoute = route); Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); topRoute = null; previousTopRoute = null; pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; @@ -168,6 +171,44 @@ void main () { check(find.byType(BottomSheet)).findsOne(); } + /// Taps the [buttonFinder] button and awaits the bottom sheet's exit. + /// + /// Includes a check that the bottom sheet is gone. + /// Also awaits the transition to a new pushed route, if one is pushed. + /// + /// [buttonFinder] will be run only in the bottom sheet's subtree; + /// it doesn't need its own `find.descendant` logic. + Future tapButtonAndAwaitTransition(WidgetTester tester, Finder buttonFinder) async { + final topRouteBeforePress = topRoute; + check(topRouteBeforePress).isA>(); + final numPushedRoutesBeforePress = pushedRoutes.length; + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: buttonFinder)); + await tester.pump(Duration.zero); + + final newPushedRoute = pushedRoutes.skip(numPushedRoutesBeforePress) + .singleOrNull; + + final sheetPopDuration = (topRouteBeforePress as ModalBottomSheetRoute) + .reverseTransitionDuration; + // TODO not sure why a 1ms fudge is needed; investigate. + await tester.pump(sheetPopDuration + Duration(milliseconds: 1)); + check(find.byType(BottomSheet)).findsNothing(); + + if (newPushedRoute != null) { + final pushDuration = (newPushedRoute as TransitionRoute).transitionDuration; + if (pushDuration > sheetPopDuration) { + await tester.pump(pushDuration - sheetPopDuration); + } + } + + // We dismissed the sheet by popping, not pushing or replacing. + check(topRouteBeforePress as Route?) + ..not((it) => it.identicalTo(topRoute)) + ..identicalTo(lastPoppedRoute); + } + void checkIconSelected(WidgetTester tester, Finder finder) { final widget = tester.widget(find.descendant( of: find.byType(BottomSheet), @@ -190,9 +231,7 @@ void main () { await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); - await tester.tap(find.text('Cancel')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapButtonAndAwaitTransition(tester, find.text('Cancel')); await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); @@ -211,12 +250,7 @@ void main () { check(find.byType(InboxPageBody)).findsOne(); check(find.byType(SubscriptionListPageBody)).findsNothing(); - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: channelsMenuIconFinder)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); check(find.byType(InboxPageBody)).findsNothing(); check(find.byType(SubscriptionListPageBody)).findsOne(); @@ -228,23 +262,13 @@ void main () { testWidgets('navigation bar menu buttons dismiss the menu', (tester) async { await prepare(tester); await tapOpenMenuAndAwait(tester); - - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: channelsMenuIconFinder)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); }); testWidgets('cancel button dismisses the menu', (tester) async { await prepare(tester); await tapOpenMenuAndAwait(tester); - - await tester.tap(find.text('Cancel')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation - check(find.byType(BottomSheet)).findsNothing(); + await tapButtonAndAwaitTransition(tester, find.text('Cancel')); }); testWidgets('menu buttons dismiss the menu', (tester) async { @@ -252,6 +276,7 @@ void main () { topRoute = null; previousTopRoute = null; pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); @@ -263,11 +288,7 @@ void main () { connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [eg.streamMessage()]).toJson()); - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: combinedFeedMenuIconFinder)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapButtonAndAwaitTransition(tester, combinedFeedMenuIconFinder); // When we go back to the home page, the menu sheet should be gone. (await ZulipApp.navigator).pop(); @@ -278,10 +299,7 @@ void main () { testWidgets('_MyProfileButton', (tester) async { await prepare(tester); await tapOpenMenuAndAwait(tester); - - await tester.tap(find.text('My profile')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapButtonAndAwaitTransition(tester, find.text('My profile')); check(find.byType(ProfilePage)).findsOne(); check(find.text(eg.selfUser.fullName)).findsAny(); }); @@ -289,10 +307,7 @@ void main () { testWidgets('_AboutZulipButton', (tester) async { await prepare(tester); await tapOpenMenuAndAwait(tester); - - await tester.tap(find.byIcon(ZulipIcons.info)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tapButtonAndAwaitTransition(tester, find.byIcon(ZulipIcons.info)); check(find.byType(AboutZulipPage)).findsOne(); }); }); From 64cfee9e7afe3da55dcfbe9984527c735cea8e35 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 15:55:05 -0700 Subject: [PATCH 110/290] home test: Finish making menu tests robust to route animation changes --- test/widgets/home_test.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index a1cb6667a1..62ee8e4e19 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -291,8 +291,12 @@ void main () { await tapButtonAndAwaitTransition(tester, combinedFeedMenuIconFinder); // When we go back to the home page, the menu sheet should be gone. + final topBeforePop = topRoute; + check(topBeforePop).isNotNull().isA() + .page.isA().initNarrow.equals(CombinedFeedNarrow()); (await ZulipApp.navigator).pop(); - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump((topBeforePop as TransitionRoute).reverseTransitionDuration); + check(find.byType(BottomSheet)).findsNothing(); }); From 1fd5844dffc5ceaec8f34e282bf097cd99cc0841 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 16:18:05 -0700 Subject: [PATCH 111/290] home test [nfc]: Name a helper more helpfully --- test/widgets/home_test.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 62ee8e4e19..a9a3ba6b07 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -336,7 +336,7 @@ void main () { checkOnLoadingPage(); } - Future tapChooseAccount(WidgetTester tester) async { + Future tapTryAnotherAccount(WidgetTester tester) async { await tester.tap(find.text('Try another account')); await tester.pump(Duration.zero); // tap the button await tester.pump(const Duration(milliseconds: 250)); // wait for animation @@ -377,7 +377,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); await tester.tap(find.byType(BackButton)); await tester.pump(Duration.zero); // tap the button @@ -392,7 +392,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration * 2; await chooseAccountWithEmail(tester, eg.otherAccount.email); @@ -410,7 +410,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // While still loading, choose a different account. await chooseAccountWithEmail(tester, eg.otherAccount.email); @@ -432,7 +432,7 @@ void main () { await tester.pump(kTryAnotherAccountWaitPeriod); // While still loading the first account, choose a different account. - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); await chooseAccountWithEmail(tester, eg.otherAccount.email); // User cannot go back because the navigator stack // was cleared after choosing an account. @@ -443,7 +443,7 @@ void main () { await tester.pump(kTryAnotherAccountWaitPeriod); // While still loading the second account, choose a different account. - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); await chooseAccountWithEmail(tester, thirdAccount.email); // User cannot go back because the navigator stack // was cleared after choosing an account. @@ -460,7 +460,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // Stall while on ChoooseAccountPage so that the account finished loading. await tester.pump(loadPerAccountDuration); @@ -476,7 +476,7 @@ void main () { testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; await prepare(tester); await tester.pump(kTryAnotherAccountWaitPeriod); - await tapChooseAccount(tester); + await tapTryAnotherAccount(tester); // Stall while on ChoooseAccountPage so that the account finished loading. await tester.pump(loadPerAccountDuration); From 834834b514ebc019f6ed80785d792009eb29a1e0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 17:17:08 -0700 Subject: [PATCH 112/290] home test: Make loading-page tests robust to route animation changes This should fix all the failing tests in flutter/flutter#165832. Discussion: https://github.com/flutter/flutter/pull/165832#issuecomment-2932603077 --- test/widgets/home_test.dart | 51 +++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index a9a3ba6b07..efad9e6b9a 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -329,24 +329,38 @@ void main () { Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + topRoute = null; + previousTopRoute = null; + pushedRoutes = []; + lastPoppedRoute = null; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot()); - await tester.pumpWidget(const ZulipApp()); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); await tester.pump(Duration.zero); // wait for the loading page checkOnLoadingPage(); } Future tapTryAnotherAccount(WidgetTester tester) async { + final numPushedRoutesBefore = pushedRoutes.length; await tester.tap(find.text('Try another account')); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 250)); // wait for animation + await tester.pump(); + final pushedRoute = pushedRoutes.skip(numPushedRoutesBefore).single; + check(pushedRoute).isA().page.isA(); + await tester.pump((pushedRoute as TransitionRoute).transitionDuration); checkOnChooseAccountPage(); } Future chooseAccountWithEmail(WidgetTester tester, String email) async { + lastPoppedRoute = null; await tester.tap(find.text(email)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for push & pop animations + await tester.pump(); + check(topRoute).isA().page.isA(); + check(lastPoppedRoute).isA().page.isA(); + final popDuration = (lastPoppedRoute as TransitionRoute).reverseTransitionDuration; + final pushDuration = (topRoute as TransitionRoute).transitionDuration; + final animationDuration = popDuration > pushDuration ? popDuration : pushDuration; + // TODO not sure why a 1ms fudge is needed; investigate. + await tester.pump(animationDuration + Duration(milliseconds: 1)); checkOnLoadingPage(); } @@ -379,9 +393,14 @@ void main () { await tester.pump(kTryAnotherAccountWaitPeriod); await tapTryAnotherAccount(tester); + lastPoppedRoute = null; await tester.tap(find.byType(BackButton)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); checkOnLoadingPage(); await tester.pump(loadPerAccountDuration); @@ -466,9 +485,14 @@ void main () { await tester.pump(loadPerAccountDuration); checkOnChooseAccountPage(); + lastPoppedRoute = null; await tester.tap(find.byType(BackButton)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); checkOnHomePage(tester, expectedAccount: eg.selfAccount); }); @@ -483,9 +507,14 @@ void main () { checkOnChooseAccountPage(); // Choosing the already loaded account should result in no loading page. + lastPoppedRoute = null; await tester.tap(find.text(eg.selfAccount.email)); - await tester.pump(Duration.zero); // tap the button - await tester.pump(const Duration(milliseconds: 350)); // wait for push & pop animations + await tester.pump(); + check(lastPoppedRoute).isA().page.isA(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); // No additional wait for loadPerAccount. checkOnHomePage(tester, expectedAccount: eg.selfAccount); }); From d3b0b43fcbad2a86b1bfda40e41777394985152f Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 3 Jun 2025 14:14:53 +0530 Subject: [PATCH 113/290] licenses test: Add a smoke test for `additionalLicenses` Skipped for now, because of #1540. --- test/licenses_test.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 test/licenses_test.dart diff --git a/test/licenses_test.dart b/test/licenses_test.dart new file mode 100644 index 0000000000..045690885b --- /dev/null +++ b/test/licenses_test.dart @@ -0,0 +1,14 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/licenses.dart'; + +import 'fake_async.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('smoke: ensure all additional licenses load', () => awaitFakeAsync((async) async { + await check(additionalLicenses().toList()) + .completes((it) => it.isNotEmpty()); + }), skip: true); // TODO(#1540) +} From fc874aebb11fa3ad89ac05d09f916c884fd8502e Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 3 Jun 2025 13:30:19 +0530 Subject: [PATCH 114/290] licenses: Add asset entry for KaTeX license We forgot to add that when we added the KaTeX fonts and the LICENSE file in 829dae9af. Fixes: #1540 --- pubspec.yaml | 1 + test/licenses_test.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index b49bf1fd7d..4a94d439c5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -115,6 +115,7 @@ flutter: uses-material-design: true assets: + - assets/KaTeX/LICENSE - assets/Noto_Color_Emoji/LICENSE - assets/Pygments/AUTHORS.txt - assets/Pygments/LICENSE.txt diff --git a/test/licenses_test.dart b/test/licenses_test.dart index 045690885b..8e5cf1fe33 100644 --- a/test/licenses_test.dart +++ b/test/licenses_test.dart @@ -10,5 +10,5 @@ void main() { test('smoke: ensure all additional licenses load', () => awaitFakeAsync((async) async { await check(additionalLicenses().toList()) .completes((it) => it.isNotEmpty()); - }), skip: true); // TODO(#1540) + })); } From ebea81838d0e9eef1fffc2c8e1b4082b948bfaf3 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 10 Jun 2025 16:45:31 -0700 Subject: [PATCH 115/290] settings [nfc]: Suppress some new deprecation warnings See #1546 for a PR that would resolve them, and why we're not doing that just now (it would require upgrading Flutter past a breaking change). --- lib/widgets/settings.dart | 3 +++ test/flutter_checks.dart | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 9e7581c539..a96fb82928 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -53,7 +53,10 @@ class _ThemeSetting extends StatelessWidget { themeSetting: themeSettingOption, zulipLocalizations: zulipLocalizations)), value: themeSettingOption, + // TODO(#1545) stop using the deprecated members + // ignore: deprecated_member_use groupValue: globalSettings.themeSetting, + // ignore: deprecated_member_use onChanged: (newValue) => _handleChange(context, newValue)), ]); } diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 0bfbd2d33a..1bafd6636f 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -251,5 +251,7 @@ extension SwitchListTileChecks on Subject { } extension RadioListTileChecks on Subject> { + // TODO(#1545) stop using the deprecated member + // ignore: deprecated_member_use Subject get checked => has((x) => x.checked, 'checked'); } From 618a75ce7bd061524ec2b011d42dd136d89f2192 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 5 Jun 2025 17:48:25 -0700 Subject: [PATCH 116/290] home: Add placeholder text for empty Inbox, Channels, and Direct messages The Figma has some graphics on all of these, but we'll leave that for later: see issue #1551. Fixes: #385 Fixes: #386 --- assets/l10n/app_en.arb | 16 ++++++--- lib/generated/l10n/zulip_localizations.dart | 24 +++++++++---- .../l10n/zulip_localizations_ar.dart | 15 ++++++-- .../l10n/zulip_localizations_de.dart | 15 ++++++-- .../l10n/zulip_localizations_en.dart | 15 ++++++-- .../l10n/zulip_localizations_ja.dart | 15 ++++++-- .../l10n/zulip_localizations_nb.dart | 15 ++++++-- .../l10n/zulip_localizations_pl.dart | 15 ++++++-- .../l10n/zulip_localizations_ru.dart | 15 ++++++-- .../l10n/zulip_localizations_sk.dart | 15 ++++++-- .../l10n/zulip_localizations_uk.dart | 15 ++++++-- .../l10n/zulip_localizations_zh.dart | 15 ++++++-- lib/widgets/home.dart | 34 +++++++++++++++++++ lib/widgets/inbox.dart | 8 +++++ lib/widgets/recent_dm_conversations.dart | 9 +++++ lib/widgets/subscription_list.dart | 30 ++++------------ lib/widgets/theme.dart | 7 ++++ test/widgets/inbox_test.dart | 1 + .../widgets/recent_dm_conversations_test.dart | 7 ++++ test/widgets/subscription_list_test.dart | 3 +- 20 files changed, 225 insertions(+), 64 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index aa01eda043..487b519bb5 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -781,6 +781,10 @@ "@inboxPageTitle": { "description": "Title for the page with unreads." }, + "inboxEmptyPlaceholder": "There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, "recentDmConversationsPageTitle": "Direct messages", "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." @@ -789,6 +793,10 @@ "@recentDmConversationsSectionHeader": { "description": "Heading for direct messages section on the 'Inbox' message view." }, + "recentDmConversationsEmptyPlaceholder": "You have no direct messages yet! Why not start the conversation?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, "combinedFeedPageTitle": "Combined feed", "@combinedFeedPageTitle": { "description": "Page title for the 'Combined feed' message view." @@ -805,6 +813,10 @@ "@channelsPageTitle": { "description": "Title for the page with a list of subscribed channels." }, + "channelsEmptyPlaceholder": "You are not subscribed to any channels yet.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, "mainMenuMyProfile": "My profile", "@mainMenuMyProfile": { "description": "Label for main-menu button leading to the user's own profile." @@ -833,10 +845,6 @@ "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." }, - "subscriptionListNoChannels": "No channels found", - "@subscriptionListNoChannels": { - "description": "Text to display on subscribed-channels page when there are no subscribed channels." - }, "notifSelfUser": "You", "@notifSelfUser": { "description": "Display name for the user themself, to show after replying in an Android notification" diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 306596044b..f54e67f029 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1181,6 +1181,12 @@ abstract class ZulipLocalizations { /// **'Inbox'** String get inboxPageTitle; + /// Centered text on the 'Inbox' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'** + String get inboxEmptyPlaceholder; + /// Title for the page with a list of DM conversations. /// /// In en, this message translates to: @@ -1193,6 +1199,12 @@ abstract class ZulipLocalizations { /// **'Direct messages'** String get recentDmConversationsSectionHeader; + /// Centered text on the 'Direct messages' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'You have no direct messages yet! Why not start the conversation?'** + String get recentDmConversationsEmptyPlaceholder; + /// Page title for the 'Combined feed' message view. /// /// In en, this message translates to: @@ -1217,6 +1229,12 @@ abstract class ZulipLocalizations { /// **'Channels'** String get channelsPageTitle; + /// Centered text on the 'Channels' page saying that there is no content to show. + /// + /// In en, this message translates to: + /// **'You are not subscribed to any channels yet.'** + String get channelsEmptyPlaceholder; + /// Label for main-menu button leading to the user's own profile. /// /// In en, this message translates to: @@ -1253,12 +1271,6 @@ abstract class ZulipLocalizations { /// **'Unpinned'** String get unpinnedSubscriptionsLabel; - /// Text to display on subscribed-channels page when there are no subscribed channels. - /// - /// In en, this message translates to: - /// **'No channels found'** - String get subscriptionListNoChannels; - /// Display name for the user themself, to show after replying in an Android notification /// /// 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 92b2ce681c..4367346f5d 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -639,12 +639,20 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -657,6 +665,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; @@ -683,9 +695,6 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 54faf4fde4..1b305f0d00 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -639,12 +639,20 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -657,6 +665,10 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; @@ -683,9 +695,6 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index dacb23923a..c5ac2018d0 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -639,12 +639,20 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -657,6 +665,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; @@ -683,9 +695,6 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 9d7e3ce291..745e4ee726 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -639,12 +639,20 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -657,6 +665,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; @@ -683,9 +695,6 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 92f9e33706..d0f1d0cb4b 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -639,12 +639,20 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -657,6 +665,10 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; @@ -683,9 +695,6 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 2657910637..423aef00fd 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -648,12 +648,20 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get inboxPageTitle => 'Odebrane'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Wiadomości bezpośrednie'; @override String get recentDmConversationsSectionHeader => 'Wiadomości bezpośrednie'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Mieszany widok'; @@ -666,6 +674,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanały'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'Mój profil'; @@ -692,9 +704,6 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Odpięte'; - @override - String get subscriptionListNoChannels => 'Nie odnaleziono kanałów'; - @override String get notifSelfUser => 'Ty'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index bd4ee4423c..2e3a876f0d 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -652,12 +652,20 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get inboxPageTitle => 'Входящие'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Личные сообщения'; @override String get recentDmConversationsSectionHeader => 'Личные сообщения'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Объединенная лента'; @@ -670,6 +678,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get channelsPageTitle => 'Каналы'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'Мой профиль'; @@ -696,9 +708,6 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Откреплены'; - @override - String get subscriptionListNoChannels => 'Каналы не найдены'; - @override String get notifSelfUser => 'Вы'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 93103de344..0e477ccd26 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -641,12 +641,20 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Priama správa'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Zlúčený kanál'; @@ -659,6 +667,10 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanály'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'Môj profil'; @@ -685,9 +697,6 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'Ty'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 9f49e2df4d..ca78daad56 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -651,12 +651,20 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get inboxPageTitle => 'Вхідні'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Особисті повідомлення'; @override String get recentDmConversationsSectionHeader => 'Особисті повідомлення'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Об\'єднана стрічка'; @@ -669,6 +677,10 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get channelsPageTitle => 'Канали'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'Мій профіль'; @@ -695,9 +707,6 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Відкріплені'; - @override - String get subscriptionListNoChannels => 'Канали не знайдено'; - @override String get notifSelfUser => 'Ви'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 8b3760c36d..85f054ae34 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -639,12 +639,20 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get inboxPageTitle => 'Inbox'; + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @override String get recentDmConversationsSectionHeader => 'Direct messages'; + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + @override String get combinedFeedPageTitle => 'Combined feed'; @@ -657,6 +665,10 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + @override String get mainMenuMyProfile => 'My profile'; @@ -683,9 +695,6 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get unpinnedSubscriptionsLabel => 'Unpinned'; - @override - String get subscriptionListNoChannels => 'No channels found'; - @override String get notifSelfUser => 'You'; diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index ab5ad446db..404472f7d0 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -148,6 +148,40 @@ class _HomePageState extends State { } } +/// A "no content here" message, for the Inbox, Subscriptions, and DMs pages. +/// +/// This should go near the root of the "page body"'s widget subtree. +/// In particular, it handles the horizontal device insets. +/// (The vertical insets are handled externally, by the app bar and bottom nav.) +class PageBodyEmptyContentPlaceholder extends StatelessWidget { + const PageBodyEmptyContentPlaceholder({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return SafeArea( + minimum: EdgeInsets.symmetric(horizontal: 24), + child: Padding( + padding: EdgeInsets.only(top: 48, bottom: 16), + child: Align( + alignment: Alignment.topCenter, + // TODO leading and trailing elements, like in Figma (given as SVGs): + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev + child: Text( + textAlign: TextAlign.center, + style: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 23 / 17, + ).merge(weightVariableTextStyle(context, wght: 500)), + message)))); + } +} + + const kTryAnotherAccountWaitPeriod = Duration(seconds: 5); class _LoadingPlaceholderPage extends StatefulWidget { diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 0f6a5c75a1..702e4135bf 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -6,6 +6,7 @@ import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'action_sheet.dart'; +import 'home.dart'; import 'icons.dart'; import 'message_list.dart'; import 'sticky_header.dart'; @@ -82,6 +83,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final subscriptions = store.subscriptions; @@ -160,6 +162,12 @@ class _InboxPageState extends State with PerAccountStoreAwareStat sections.add(_StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); } + if (sections.isEmpty) { + return PageBodyEmptyContentPlaceholder( + // TODO(#315) add e.g. "You might be interested in recent conversations." + message: zulipLocalizations.inboxEmptyPlaceholder); + } + return SafeArea( // Don't pad the bottom here; we want the list content to do that. bottom: false, diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 982dde4f08..9c899cc146 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'content.dart'; +import 'home.dart'; import 'icons.dart'; import 'message_list.dart'; import 'store.dart'; @@ -48,7 +50,14 @@ class _RecentDmConversationsPageBodyState extends State wit _sortSubs(pinned); _sortSubs(unpinned); + if (pinned.isEmpty && unpinned.isEmpty) { + return PageBodyEmptyContentPlaceholder( + // TODO(#188) add e.g. "Go to 'All channels' and join some of them." + message: zulipLocalizations.channelsEmptyPlaceholder); + } + return SafeArea( // Don't pad the bottom here; we want the list content to do that. bottom: false, child: CustomScrollView( slivers: [ - if (pinned.isEmpty && unpinned.isEmpty) - const _NoSubscriptionsItem(), if (pinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.pinnedSubscriptionsLabel), _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), @@ -118,27 +123,6 @@ class _SubscriptionListPageBodyState extends State wit } } -class _NoSubscriptionsItem extends StatelessWidget { - const _NoSubscriptionsItem(); - - @override - Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(10), - child: Text(zulipLocalizations.subscriptionListNoChannels, - textAlign: TextAlign.center, - style: TextStyle( - color: designVariables.subscriptionListHeaderText, - fontSize: 18, - height: (20 / 18), - )))); - } -} - class _SubscriptionListHeader extends StatelessWidget { const _SubscriptionListHeader({required this.label}); diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 492f82c88a..1e5a6fe6ae 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -164,6 +164,7 @@ class DesignVariables extends ThemeExtension { labelCounterUnread: const Color(0xff222222), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), + labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), mainBackground: const Color(0xfff0f0f0), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), @@ -225,6 +226,7 @@ class DesignVariables extends ThemeExtension { labelCounterUnread: const Color(0xffffffff).withValues(alpha: 0.7), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), + labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), mainBackground: const Color(0xff1d1d1d), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff).withValues(alpha: 0.9), @@ -294,6 +296,7 @@ class DesignVariables extends ThemeExtension { required this.labelCounterUnread, required this.labelEdited, required this.labelMenuButton, + required this.labelSearchPrompt, required this.mainBackground, required this.textInput, required this.title, @@ -364,6 +367,7 @@ class DesignVariables extends ThemeExtension { final Color labelCounterUnread; final Color labelEdited; final Color labelMenuButton; + final Color labelSearchPrompt; final Color mainBackground; final Color textInput; final Color title; @@ -429,6 +433,7 @@ class DesignVariables extends ThemeExtension { Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, + Color? labelSearchPrompt, Color? mainBackground, Color? textInput, Color? title, @@ -489,6 +494,7 @@ class DesignVariables extends ThemeExtension { labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, + labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, mainBackground: mainBackground ?? this.mainBackground, textInput: textInput ?? this.textInput, title: title ?? this.title, @@ -556,6 +562,7 @@ class DesignVariables extends ThemeExtension { labelCounterUnread: Color.lerp(labelCounterUnread, other.labelCounterUnread, t)!, labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, + labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index c91b70cb44..4d24e5d831 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -196,6 +196,7 @@ void main() { group('InboxPage', () { testWidgets('page builds; empty', (tester) async { await setupPage(tester, unreadMessages: []); + check(find.textContaining('There are no unread messages in your inbox.')).findsOne(); }); // TODO more checks: ordering, etc. diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 44322ccea1..7568c52043 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.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/api/model/model.dart'; @@ -68,6 +69,12 @@ void main() { (widget) => widget is RecentDmConversationsItem && widget.narrow == narrow, ); + testWidgets('appearance when empty', (tester) async { + await setupPage(tester, users: [], dmMessages: []); + check(find.text('You have no direct messages yet! Why not start the conversation?')) + .findsOne(); + }); + testWidgets('page builds; conversations appear in order', (tester) async { final user1 = eg.user(userId: 1); final user2 = eg.user(userId: 2); diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index a3fc13dac9..57e8af8e29 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -57,11 +57,12 @@ void main() { return find.byType(SubscriptionItem).evaluate().length; } - testWidgets('smoke', (tester) async { + testWidgets('empty', (tester) async { await setupStreamListPage(tester, subscriptions: []); check(getItemCount()).equals(0); check(isPinnedHeaderInTree()).isFalse(); check(isUnpinnedHeaderInTree()).isFalse(); + check(find.text('You are not subscribed to any channels yet.')).findsOne(); }); testWidgets('basic subscriptions', (tester) async { From debe99e9c1170fb58c510a5d2ba5bc4ac67639e4 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 8 May 2025 15:27:46 -0700 Subject: [PATCH 117/290] msglist [nfc]: Build end-of-feed widgets in a helper method This will give us a natural home for logic that makes these depend on whether we have the newest messages, once that becomes something that varies. --- lib/widgets/message_list.dart | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index e25445e514..218fed1a7b 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -686,15 +686,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat if (childIndex < 0) return null; return childIndex; }, - childCount: bottomItems + 3, + childCount: bottomItems + 1, (context, childIndex) { - // To reinforce that the end of the feed has been reached: - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 - if (childIndex == bottomItems + 2) return const SizedBox(height: 36); - - if (childIndex == bottomItems + 1) return MarkAsReadWidget(narrow: widget.narrow); - - if (childIndex == bottomItems) return TypingStatusWidget(narrow: widget.narrow); + if (childIndex == bottomItems) return _buildEndCap(); final itemIndex = topItems + childIndex; final data = model.items[itemIndex]; @@ -743,6 +737,16 @@ class _MessageListState extends State with PerAccountStoreAwareStat }; } + Widget _buildEndCap() { + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + TypingStatusWidget(narrow: widget.narrow), + MarkAsReadWidget(narrow: widget.narrow), + // To reinforce that the end of the feed has been reached: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 + const SizedBox(height: 36), + ]); + } + Widget _buildItem(MessageListItem data) { switch (data) { case MessageListRecipientHeaderItem(): From d831280afb940ab00b21ed9fcd30f82f101259e6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 18:38:31 -0700 Subject: [PATCH 118/290] msglist [nfc]: Say we'll show "loading" even when fetch is at other end This is NFC ultimately because we currently only ever fetch, or show loading indicators, at one end of the message list, namely the start. When we do start supporting a message list in the middle of history, though (#82), and consequently loading newer as well as older messages, my conclusion after thinking it through is that we'll want a "busy fetching" state at one end to mean we show a loading indicator at the other end too, if it still has more to be fetched. This would look weird if the user actually saw both at the same time -- but that shouldn't happen, because if both ends (or even either end) is still open then the original fetch should have found plenty of messages to separate them, many screenfuls' worth. And conversely, if the user does kick off a fetch at one end and then scroll swiftly to the other end and witness how that appears, we want to show them a "loading" sign. The situation is exactly like if they'd had a fetch attempt on that same end and we were backing off from failure: there's no fetch right now, but even though a fetch is needed (because the user is looking at the incomplete end of the known history), the app will hold off from starting one right now. Declining to start a fetch when one is needed means effectively that the loading is busy. --- lib/widgets/message_list.dart | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 218fed1a7b..9c990c333f 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -725,16 +725,21 @@ class _MessageListState extends State with PerAccountStoreAwareStat } Widget _buildStartCap() { - // These assertions are invariants of [MessageListView]. + // If we're done fetching older messages, show that. + // Else if we're busy with fetching, then show a loading indicator. + // + // This applies even if the fetch is over, but failed, and we're still + // in backoff from it; and even if the fetch is/was for the other direction. + // The loading indicator really means "busy, working on it"; and that's the + // right summary even if the fetch is internally queued behind other work. + + // (This assertion is an invariant of [MessageListView].) assert(!(model.fetchingOlder && model.fetchOlderCoolingDown)); - final effectiveFetchingOlder = + final busyFetchingMore = model.fetchingOlder || model.fetchOlderCoolingDown; - assert(!(model.haveOldest && effectiveFetchingOlder)); - return switch ((effectiveFetchingOlder, model.haveOldest)) { - (true, _) => const _MessageListLoadingMore(), - (_, true) => const _MessageListHistoryStart(), - (_, _) => const SizedBox.shrink(), - }; + return model.haveOldest ? const _MessageListHistoryStart() + : busyFetchingMore ? const _MessageListLoadingMore() + : const SizedBox.shrink(); } Widget _buildEndCap() { From 3be937750d20fddc436d140e332dbcbebe340511 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 18:22:11 -0700 Subject: [PATCH 119/290] msglist [nfc]: Use `fetched` getter when reading Generally this is helpful because it means that viewing references to the field will highlight specifically the places that set it. Here it's also helpful because we're about to replace the field with an enum shared across several getters. --- lib/model/message_list.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 8022ba8a7d..bf86c35088 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -697,7 +697,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { if (!narrow.containsMessage(message) || !_messageVisible(message)) { return; } - if (!_fetched) { + if (!fetched) { // TODO mitigate this fetch/event race: save message to add to list later return; } From b1f97c626730ff68df1833c2c0d5c63421e20748 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 18:29:10 -0700 Subject: [PATCH 120/290] msglist [nfc]: Use an enum for fetched/fetching/backoff state This makes the relationships between these flags clearer. It will also simplify some upcoming refactors that change their semantics. --- lib/model/message_list.dart | 48 +++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index bf86c35088..0a53234f24 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -64,6 +64,23 @@ class MessageListMessageItem extends MessageListMessageBaseItem { }); } +/// The status of outstanding or recent fetch requests from a [MessageListView]. +enum FetchingStatus { + /// The model hasn't successfully completed a `fetchInitial` request + /// (since its last reset, if any). + unfetched, + + /// The model made a successful `fetchInitial` request, + /// and has no outstanding requests or backoff. + idle, + + /// The model has an active `fetchOlder` request. + fetchOlder, + + /// The model is in a backoff period from a failed `fetchOlder` request. + fetchOlderCoolingDown, +} + /// The sequence of messages in a message list, and how to display them. /// /// This comprises much of the guts of [MessageListView]. @@ -95,8 +112,7 @@ mixin _MessageSequence { /// /// This allows the UI to distinguish "still working on fetching messages" /// from "there are in fact no messages here". - bool get fetched => _fetched; - bool _fetched = false; + bool get fetched => _status != FetchingStatus.unfetched; /// Whether we know we have the oldest messages for this narrow. /// @@ -113,8 +129,7 @@ mixin _MessageSequence { /// the same response each time. /// /// See also [fetchOlderCoolingDown]. - bool get fetchingOlder => _fetchingOlder; - bool _fetchingOlder = false; + bool get fetchingOlder => _status == FetchingStatus.fetchOlder; /// Whether [fetchOlder] had a request error recently. /// @@ -127,8 +142,9 @@ mixin _MessageSequence { /// when a [fetchOlder] request succeeds. /// /// See also [fetchingOlder]. - bool get fetchOlderCoolingDown => _fetchOlderCoolingDown; - bool _fetchOlderCoolingDown = false; + bool get fetchOlderCoolingDown => _status == FetchingStatus.fetchOlderCoolingDown; + + FetchingStatus _status = FetchingStatus.unfetched; BackoffMachine? _fetchOlderCooldownBackoffMachine; @@ -303,10 +319,8 @@ mixin _MessageSequence { generation += 1; messages.clear(); middleMessage = 0; - _fetched = false; _haveOldest = false; - _fetchingOlder = false; - _fetchOlderCoolingDown = false; + _status = FetchingStatus.unfetched; _fetchOlderCooldownBackoffMachine = null; contents.clear(); items.clear(); @@ -520,6 +534,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { // TODO(#82): fetch from a given message ID as anchor assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown); assert(messages.isEmpty && contents.isEmpty); + assert(_status == FetchingStatus.unfetched); // TODO schedule all this in another isolate final generation = this.generation; final result = await getMessages(store.connection, @@ -543,7 +558,8 @@ class MessageListView with ChangeNotifier, _MessageSequence { _addMessage(message); // Now [middleMessage] is the last message (the one just added). } - _fetched = true; + assert(_status == FetchingStatus.unfetched); + _status = FetchingStatus.idle; _haveOldest = result.foundOldest; notifyListeners(); } @@ -590,7 +606,8 @@ class MessageListView with ChangeNotifier, _MessageSequence { // We only intend to send "with" in [fetchInitial]; see there. || (narrow as TopicNarrow).with_ == null); assert(messages.isNotEmpty); - _fetchingOlder = true; + assert(_status == FetchingStatus.idle); + _status = FetchingStatus.fetchOlder; notifyListeners(); final generation = this.generation; bool hasFetchError = false; @@ -628,17 +645,18 @@ class MessageListView with ChangeNotifier, _MessageSequence { _haveOldest = result.foundOldest; } finally { if (this.generation == generation) { - _fetchingOlder = false; + assert(_status == FetchingStatus.fetchOlder); if (hasFetchError) { - assert(!fetchOlderCoolingDown); - _fetchOlderCoolingDown = true; + _status = FetchingStatus.fetchOlderCoolingDown; unawaited((_fetchOlderCooldownBackoffMachine ??= BackoffMachine()) .wait().then((_) { if (this.generation != generation) return; - _fetchOlderCoolingDown = false; + assert(_status == FetchingStatus.fetchOlderCoolingDown); + _status = FetchingStatus.idle; notifyListeners(); })); } else { + _status = FetchingStatus.idle; _fetchOlderCooldownBackoffMachine = null; } notifyListeners(); From babef41de54241a6ead2698ab2d1708f2b605b52 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 3 May 2025 23:13:22 -0700 Subject: [PATCH 121/290] msglist [nfc]: Split unfetched vs fetchInitial states, just for asserts --- lib/model/message_list.dart | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 0a53234f24..8ad33dd348 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -66,9 +66,11 @@ class MessageListMessageItem extends MessageListMessageBaseItem { /// The status of outstanding or recent fetch requests from a [MessageListView]. enum FetchingStatus { - /// The model hasn't successfully completed a `fetchInitial` request - /// (since its last reset, if any). - unfetched, + /// The model has not made any fetch requests (since its last reset, if any). + unstarted, + + /// The model has made a `fetchInitial` request, which hasn't succeeded. + fetchInitial, /// The model made a successful `fetchInitial` request, /// and has no outstanding requests or backoff. @@ -112,7 +114,10 @@ mixin _MessageSequence { /// /// This allows the UI to distinguish "still working on fetching messages" /// from "there are in fact no messages here". - bool get fetched => _status != FetchingStatus.unfetched; + bool get fetched => switch (_status) { + FetchingStatus.unstarted || FetchingStatus.fetchInitial => false, + _ => true, + }; /// Whether we know we have the oldest messages for this narrow. /// @@ -144,7 +149,7 @@ mixin _MessageSequence { /// See also [fetchingOlder]. bool get fetchOlderCoolingDown => _status == FetchingStatus.fetchOlderCoolingDown; - FetchingStatus _status = FetchingStatus.unfetched; + FetchingStatus _status = FetchingStatus.unstarted; BackoffMachine? _fetchOlderCooldownBackoffMachine; @@ -320,7 +325,7 @@ mixin _MessageSequence { messages.clear(); middleMessage = 0; _haveOldest = false; - _status = FetchingStatus.unfetched; + _status = FetchingStatus.unstarted; _fetchOlderCooldownBackoffMachine = null; contents.clear(); items.clear(); @@ -534,7 +539,8 @@ class MessageListView with ChangeNotifier, _MessageSequence { // TODO(#82): fetch from a given message ID as anchor assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown); assert(messages.isEmpty && contents.isEmpty); - assert(_status == FetchingStatus.unfetched); + assert(_status == FetchingStatus.unstarted); + _status = FetchingStatus.fetchInitial; // TODO schedule all this in another isolate final generation = this.generation; final result = await getMessages(store.connection, @@ -558,7 +564,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { _addMessage(message); // Now [middleMessage] is the last message (the one just added). } - assert(_status == FetchingStatus.unfetched); + assert(_status == FetchingStatus.fetchInitial); _status = FetchingStatus.idle; _haveOldest = result.foundOldest; notifyListeners(); From 5aec54d10b4e16bb65791c36e09832a4291bcab4 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 18:55:12 -0700 Subject: [PATCH 122/290] msglist [nfc]: Unify fetch/cooldown status as busyFetchingMore Now the distinction between these two states exists only for asserts. --- lib/model/message_list.dart | 48 +++++++++++------------ lib/widgets/message_list.dart | 7 +--- test/model/message_list_test.dart | 65 ++++++++++++++----------------- 3 files changed, 52 insertions(+), 68 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 8ad33dd348..c295515fb9 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -126,28 +126,18 @@ mixin _MessageSequence { bool get haveOldest => _haveOldest; bool _haveOldest = false; - /// Whether we are currently fetching the next batch of older messages. - /// - /// When this is true, [fetchOlder] is a no-op. - /// That method is called frequently by Flutter's scrolling logic, - /// and this field helps us avoid spamming the same request just to get - /// the same response each time. - /// - /// See also [fetchOlderCoolingDown]. - bool get fetchingOlder => _status == FetchingStatus.fetchOlder; - - /// Whether [fetchOlder] had a request error recently. - /// - /// When this is true, [fetchOlder] is a no-op. - /// That method is called frequently by Flutter's scrolling logic, - /// and this field mitigates spamming the same request and getting - /// the same error each time. - /// - /// "Recently" is decided by a [BackoffMachine] that resets - /// when a [fetchOlder] request succeeds. - /// - /// See also [fetchingOlder]. - bool get fetchOlderCoolingDown => _status == FetchingStatus.fetchOlderCoolingDown; + /// Whether this message list is currently busy when it comes to + /// fetching more messages. + /// + /// Here "busy" means a new call to fetch more messages would do nothing, + /// rather than make any request to the server, + /// as a result of an existing recent request. + /// This is true both when the recent request is still outstanding, + /// and when it failed and the backoff from that is still in progress. + bool get busyFetchingMore => switch (_status) { + FetchingStatus.fetchOlder || FetchingStatus.fetchOlderCoolingDown => true, + _ => false, + }; FetchingStatus _status = FetchingStatus.unstarted; @@ -168,7 +158,7 @@ mixin _MessageSequence { /// before, between, or after the messages. /// /// This information is completely derived from [messages] and - /// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown]. + /// the flags [haveOldest] and [busyFetchingMore]. /// It exists as an optimization, to memoize that computation. /// /// See also [middleItem], an index which divides this list @@ -537,7 +527,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { Future fetchInitial() async { // TODO(#80): fetch from anchor firstUnread, instead of newest // TODO(#82): fetch from a given message ID as anchor - assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown); + assert(!fetched && !haveOldest && !busyFetchingMore); assert(messages.isEmpty && contents.isEmpty); assert(_status == FetchingStatus.unstarted); _status = FetchingStatus.fetchInitial; @@ -603,10 +593,16 @@ class MessageListView with ChangeNotifier, _MessageSequence { } /// Fetch the next batch of older messages, if applicable. + /// + /// If there are no older messages to fetch (i.e. if [haveOldest]), + /// or if this message list is already busy fetching more messages + /// (i.e. if [busyFetchingMore], which includes backoff from failed requests), + /// then this method does nothing and immediately returns. + /// That makes this method suitable to call frequently, e.g. every frame, + /// whenever it looks likely to be useful to have more messages. Future fetchOlder() async { if (haveOldest) return; - if (fetchingOlder) return; - if (fetchOlderCoolingDown) return; + if (busyFetchingMore) return; assert(fetched); assert(narrow is! TopicNarrow // We only intend to send "with" in [fetchInitial]; see there. diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 9c990c333f..d0862cb1d7 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -732,13 +732,8 @@ class _MessageListState extends State with PerAccountStoreAwareStat // in backoff from it; and even if the fetch is/was for the other direction. // The loading indicator really means "busy, working on it"; and that's the // right summary even if the fetch is internally queued behind other work. - - // (This assertion is an invariant of [MessageListView].) - assert(!(model.fetchingOlder && model.fetchOlderCoolingDown)); - final busyFetchingMore = - model.fetchingOlder || model.fetchOlderCoolingDown; return model.haveOldest ? const _MessageListHistoryStart() - : busyFetchingMore ? const _MessageListLoadingMore() + : model.busyFetchingMore ? const _MessageListLoadingMore() : const SizedBox.shrink(); } diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 11f2b3056b..797f389989 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -256,12 +256,12 @@ void main() { ).toJson()); final fetchFuture = model.fetchOlder(); checkNotifiedOnce(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); await fetchFuture; checkNotifiedOnce(); check(model) - ..fetchingOlder.isFalse() + ..busyFetchingMore.isFalse() ..messages.length.equals(200); checkLastRequest( narrow: narrow.apiEncode(), @@ -285,12 +285,12 @@ void main() { ).toJson()); final fetchFuture = model.fetchOlder(); checkNotifiedOnce(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); // Don't prepare another response. final fetchFuture2 = model.fetchOlder(); checkNotNotified(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); await fetchFuture; await fetchFuture2; @@ -298,7 +298,7 @@ void main() { // prepare another response and didn't get an exception. checkNotifiedOnce(); check(model) - ..fetchingOlder.isFalse() + ..busyFetchingMore.isFalse() ..messages.length.equals(200); }); @@ -330,18 +330,17 @@ void main() { check(async.pendingTimers).isEmpty(); await check(model.fetchOlder()).throws(); checkNotified(count: 2); - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(connection.takeRequests()).single; await model.fetchOlder(); checkNotNotified(); - check(model).fetchOlderCoolingDown.isTrue(); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isTrue(); 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(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); check(connection.lastRequest).isNull(); @@ -366,7 +365,7 @@ void main() { await model.fetchOlder(); checkNotified(count: 2); check(model) - ..fetchingOlder.isFalse() + ..busyFetchingMore.isFalse() ..messages.length.equals(200); }); @@ -1068,7 +1067,7 @@ void main() { messages: olderMessages, ).toJson()); final fetchFuture = model.fetchOlder(); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkHasMessages(initialMessages); checkNotifiedOnce(); @@ -1081,7 +1080,7 @@ void main() { origStreamId: otherStream.streamId, newMessages: movedMessages, )); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkHasMessages([]); checkNotifiedOnce(); @@ -1104,7 +1103,7 @@ void main() { ).toJson()); final fetchFuture = model.fetchOlder(); checkHasMessages(initialMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); connection.prepare(delay: const Duration(seconds: 1), json: newestResult( @@ -1117,7 +1116,7 @@ void main() { newMessages: movedMessages, )); checkHasMessages([]); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); async.elapse(const Duration(seconds: 1)); @@ -1138,7 +1137,7 @@ void main() { BackoffMachine.debugDuration = const Duration(seconds: 1); await check(model.fetchOlder()).throws(); final backoffTimerA = async.pendingTimers.single; - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(model).fetched.isTrue(); checkHasMessages(initialMessages); checkNotified(count: 2); @@ -1156,36 +1155,36 @@ void main() { check(model).fetched.isFalse(); checkHasMessages([]); checkNotifiedOnce(); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isTrue(); async.elapse(Duration.zero); check(model).fetched.isTrue(); checkHasMessages(initialMessages + movedMessages); checkNotifiedOnce(); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isTrue(); connection.prepare(apiException: eg.apiBadRequest()); BackoffMachine.debugDuration = const Duration(seconds: 2); await check(model.fetchOlder()).throws(); final backoffTimerB = async.pendingTimers.last; - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(backoffTimerA.isActive).isTrue(); check(backoffTimerB.isActive).isTrue(); checkNotified(count: 2); - // When `backoffTimerA` ends, `fetchOlderCoolingDown` remains `true` + // When `backoffTimerA` ends, `busyFetchingMore` remains `true` // because the backoff was from a previous generation. async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isTrue(); + check(model).busyFetchingMore.isTrue(); check(backoffTimerA.isActive).isFalse(); check(backoffTimerB.isActive).isTrue(); checkNotNotified(); - // When `backoffTimerB` ends, `fetchOlderCoolingDown` gets reset. + // When `backoffTimerB` ends, `busyFetchingMore` gets reset. async.elapse(const Duration(seconds: 1)); - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); check(backoffTimerA.isActive).isFalse(); check(backoffTimerB.isActive).isFalse(); checkNotifiedOnce(); @@ -1267,7 +1266,7 @@ void main() { ).toJson()); final fetchFuture1 = model.fetchOlder(); checkHasMessages(initialMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); connection.prepare(delay: const Duration(seconds: 1), json: newestResult( @@ -1280,7 +1279,7 @@ void main() { newMessages: movedMessages, )); checkHasMessages([]); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); async.elapse(const Duration(seconds: 1)); @@ -1293,19 +1292,19 @@ void main() { ).toJson()); final fetchFuture2 = model.fetchOlder(); checkHasMessages(initialMessages + movedMessages); - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotifiedOnce(); await fetchFuture1; checkHasMessages(initialMessages + movedMessages); // The older fetchOlder call should not override fetchingOlder set by // the new fetchOlder call, nor should it notify the listeners. - check(model).fetchingOlder.isTrue(); + check(model).busyFetchingMore.isTrue(); checkNotNotified(); await fetchFuture2; checkHasMessages(olderMessages + initialMessages + movedMessages); - check(model).fetchingOlder.isFalse(); + check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); })); }); @@ -2140,15 +2139,10 @@ void checkInvariants(MessageListView model) { check(model) ..messages.isEmpty() ..haveOldest.isFalse() - ..fetchingOlder.isFalse() - ..fetchOlderCoolingDown.isFalse(); + ..busyFetchingMore.isFalse(); } if (model.haveOldest) { - check(model).fetchingOlder.isFalse(); - check(model).fetchOlderCoolingDown.isFalse(); - } - if (model.fetchingOlder) { - check(model).fetchOlderCoolingDown.isFalse(); + check(model).busyFetchingMore.isFalse(); } for (final message in model.messages) { @@ -2292,6 +2286,5 @@ extension MessageListViewChecks on Subject { Subject get middleItem => has((x) => x.middleItem, 'middleItem'); Subject get fetched => has((x) => x.fetched, 'fetched'); Subject get haveOldest => has((x) => x.haveOldest, 'haveOldest'); - Subject get fetchingOlder => has((x) => x.fetchingOlder, 'fetchingOlder'); - Subject get fetchOlderCoolingDown => has((x) => x.fetchOlderCoolingDown, 'fetchOlderCoolingDown'); + Subject get busyFetchingMore => has((x) => x.busyFetchingMore, 'busyFetchingMore'); } From 70c31c345f1e8504b6fc7be00d1468078eddcc28 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 18:00:02 -0700 Subject: [PATCH 123/290] msglist [nfc]: Rename backoff state to share between older/newer If a fetch in one direction has recently failed, we'll want the backoff to apply to any attempt to fetch in the other direction too; after all, it's the same server. We can also drop the term "cooldown" here, which is effectively redundant with "backoff". --- lib/model/message_list.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index c295515fb9..1943a99768 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -79,8 +79,8 @@ enum FetchingStatus { /// The model has an active `fetchOlder` request. fetchOlder, - /// The model is in a backoff period from a failed `fetchOlder` request. - fetchOlderCoolingDown, + /// The model is in a backoff period from a failed request. + backoff, } /// The sequence of messages in a message list, and how to display them. @@ -135,13 +135,13 @@ mixin _MessageSequence { /// This is true both when the recent request is still outstanding, /// and when it failed and the backoff from that is still in progress. bool get busyFetchingMore => switch (_status) { - FetchingStatus.fetchOlder || FetchingStatus.fetchOlderCoolingDown => true, + FetchingStatus.fetchOlder || FetchingStatus.backoff => true, _ => false, }; FetchingStatus _status = FetchingStatus.unstarted; - BackoffMachine? _fetchOlderCooldownBackoffMachine; + BackoffMachine? _fetchBackoffMachine; /// The parsed message contents, as a list parallel to [messages]. /// @@ -316,7 +316,7 @@ mixin _MessageSequence { middleMessage = 0; _haveOldest = false; _status = FetchingStatus.unstarted; - _fetchOlderCooldownBackoffMachine = null; + _fetchBackoffMachine = null; contents.clear(); items.clear(); middleItem = 0; @@ -649,17 +649,17 @@ class MessageListView with ChangeNotifier, _MessageSequence { if (this.generation == generation) { assert(_status == FetchingStatus.fetchOlder); if (hasFetchError) { - _status = FetchingStatus.fetchOlderCoolingDown; - unawaited((_fetchOlderCooldownBackoffMachine ??= BackoffMachine()) + _status = FetchingStatus.backoff; + unawaited((_fetchBackoffMachine ??= BackoffMachine()) .wait().then((_) { if (this.generation != generation) return; - assert(_status == FetchingStatus.fetchOlderCoolingDown); + assert(_status == FetchingStatus.backoff); _status = FetchingStatus.idle; notifyListeners(); })); } else { _status = FetchingStatus.idle; - _fetchOlderCooldownBackoffMachine = null; + _fetchBackoffMachine = null; } notifyListeners(); } From b75f1680d5e6b3d1c0f68df772eb8c5ce0092b67 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 18:36:57 -0700 Subject: [PATCH 124/290] msglist [nfc]: Rename fetchingMore status from fetchOlder This matches the symmetry expressed in the description of busyFetchingMore and at the latter's call site in widgets code: whichever direction (older or newer) we might have a fetch request active in, the consequences we draw are the same in both directions. --- lib/model/message_list.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 1943a99768..6786aeed9f 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -77,7 +77,7 @@ enum FetchingStatus { idle, /// The model has an active `fetchOlder` request. - fetchOlder, + fetchingMore, /// The model is in a backoff period from a failed request. backoff, @@ -135,7 +135,7 @@ mixin _MessageSequence { /// This is true both when the recent request is still outstanding, /// and when it failed and the backoff from that is still in progress. bool get busyFetchingMore => switch (_status) { - FetchingStatus.fetchOlder || FetchingStatus.backoff => true, + FetchingStatus.fetchingMore || FetchingStatus.backoff => true, _ => false, }; @@ -609,7 +609,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { || (narrow as TopicNarrow).with_ == null); assert(messages.isNotEmpty); assert(_status == FetchingStatus.idle); - _status = FetchingStatus.fetchOlder; + _status = FetchingStatus.fetchingMore; notifyListeners(); final generation = this.generation; bool hasFetchError = false; @@ -647,7 +647,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { _haveOldest = result.foundOldest; } finally { if (this.generation == generation) { - assert(_status == FetchingStatus.fetchOlder); + assert(_status == FetchingStatus.fetchingMore); if (hasFetchError) { _status = FetchingStatus.backoff; unawaited((_fetchBackoffMachine ??= BackoffMachine()) From 7558042370bd1bdf147a56df9d8164e51cbabbfc Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 19:38:46 -0700 Subject: [PATCH 125/290] msglist [nfc]: Pull out a _setStatus method This tightens up a bit the logic for maintaining the fetching status, and hopefully makes it a bit easier to read. --- lib/model/message_list.dart | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 6786aeed9f..517ce06cfd 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -523,14 +523,20 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + void _setStatus(FetchingStatus value, {FetchingStatus? was}) { + assert(was == null || _status == was); + _status = value; + if (!fetched) return; + notifyListeners(); + } + /// Fetch messages, starting from scratch. Future fetchInitial() async { // TODO(#80): fetch from anchor firstUnread, instead of newest // TODO(#82): fetch from a given message ID as anchor assert(!fetched && !haveOldest && !busyFetchingMore); assert(messages.isEmpty && contents.isEmpty); - assert(_status == FetchingStatus.unstarted); - _status = FetchingStatus.fetchInitial; + _setStatus(FetchingStatus.fetchInitial, was: FetchingStatus.unstarted); // TODO schedule all this in another isolate final generation = this.generation; final result = await getMessages(store.connection, @@ -554,10 +560,8 @@ class MessageListView with ChangeNotifier, _MessageSequence { _addMessage(message); // Now [middleMessage] is the last message (the one just added). } - assert(_status == FetchingStatus.fetchInitial); - _status = FetchingStatus.idle; _haveOldest = result.foundOldest; - notifyListeners(); + _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchInitial); } /// Update [narrow] for the result of a "with" narrow (topic permalink) fetch. @@ -608,9 +612,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { // We only intend to send "with" in [fetchInitial]; see there. || (narrow as TopicNarrow).with_ == null); assert(messages.isNotEmpty); - assert(_status == FetchingStatus.idle); - _status = FetchingStatus.fetchingMore; - notifyListeners(); + _setStatus(FetchingStatus.fetchingMore, was: FetchingStatus.idle); final generation = this.generation; bool hasFetchError = false; try { @@ -647,21 +649,17 @@ class MessageListView with ChangeNotifier, _MessageSequence { _haveOldest = result.foundOldest; } finally { if (this.generation == generation) { - assert(_status == FetchingStatus.fetchingMore); if (hasFetchError) { - _status = FetchingStatus.backoff; + _setStatus(FetchingStatus.backoff, was: FetchingStatus.fetchingMore); unawaited((_fetchBackoffMachine ??= BackoffMachine()) .wait().then((_) { if (this.generation != generation) return; - assert(_status == FetchingStatus.backoff); - _status = FetchingStatus.idle; - notifyListeners(); + _setStatus(FetchingStatus.idle, was: FetchingStatus.backoff); })); } else { - _status = FetchingStatus.idle; + _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchingMore); _fetchBackoffMachine = null; } - notifyListeners(); } } } From 6ff889bee4b1ebd5ad7399e56df6df1c2ac86ade Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 19:09:53 -0700 Subject: [PATCH 126/290] msglist [nfc]: Introduce haveNewest in model, always true for now --- lib/model/message_list.dart | 32 ++++++++++++++++++++++++------- test/model/message_list_test.dart | 17 ++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 517ce06cfd..f2415504fc 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -94,7 +94,7 @@ mixin _MessageSequence { /// /// This may or may not represent all the message history that /// conceptually belongs in this message list. - /// That information is expressed in [fetched] and [haveOldest]. + /// That information is expressed in [fetched], [haveOldest], [haveNewest]. /// /// See also [middleMessage], an index which divides this list /// into a top slice and a bottom slice. @@ -121,11 +121,19 @@ mixin _MessageSequence { /// Whether we know we have the oldest messages for this narrow. /// - /// (Currently we always have the newest messages for the narrow, - /// once [fetched] is true, because we start from the newest.) + /// See also [haveNewest]. bool get haveOldest => _haveOldest; bool _haveOldest = false; + /// Whether we know we have the newest messages for this narrow. + /// + /// (Currently this is always true once [fetched] is true, + /// because we start from the newest.) + /// + /// See also [haveOldest]. + bool get haveNewest => _haveNewest; + bool _haveNewest = false; + /// Whether this message list is currently busy when it comes to /// fetching more messages. /// @@ -158,7 +166,7 @@ mixin _MessageSequence { /// before, between, or after the messages. /// /// This information is completely derived from [messages] and - /// the flags [haveOldest] and [busyFetchingMore]. + /// the flags [haveOldest], [haveNewest], and [busyFetchingMore]. /// It exists as an optimization, to memoize that computation. /// /// See also [middleItem], an index which divides this list @@ -315,6 +323,7 @@ mixin _MessageSequence { messages.clear(); middleMessage = 0; _haveOldest = false; + _haveNewest = false; _status = FetchingStatus.unstarted; _fetchBackoffMachine = null; contents.clear(); @@ -534,7 +543,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { Future fetchInitial() async { // TODO(#80): fetch from anchor firstUnread, instead of newest // TODO(#82): fetch from a given message ID as anchor - assert(!fetched && !haveOldest && !busyFetchingMore); + assert(!fetched && !haveOldest && !haveNewest && !busyFetchingMore); assert(messages.isEmpty && contents.isEmpty); _setStatus(FetchingStatus.fetchInitial, was: FetchingStatus.unstarted); // TODO schedule all this in another isolate @@ -561,6 +570,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { // Now [middleMessage] is the last message (the one just added). } _haveOldest = result.foundOldest; + _haveNewest = true; // TODO(#82) _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchInitial); } @@ -715,8 +725,16 @@ class MessageListView with ChangeNotifier, _MessageSequence { if (!narrow.containsMessage(message) || !_messageVisible(message)) { return; } - if (!fetched) { - // TODO mitigate this fetch/event race: save message to add to list later + if (!haveNewest) { + // This message list's [messages] doesn't yet reach the new end + // of the narrow's message history. (Either [fetchInitial] hasn't yet + // completed, or if it has then it was in the middle of history and no + // subsequent fetch has reached the end.) + // So this still-newer message doesn't belong. + // Leave it to be found by a subsequent fetch when appropriate. + // TODO mitigate this fetch/event race: save message to add to list later, + // in case the fetch that reaches the end is already ongoing and + // didn't include this message. return; } // TODO insert in middle instead, when appropriate diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 797f389989..4dc4a4c1fb 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -150,7 +150,8 @@ void main() { checkNotifiedOnce(); check(model) ..messages.length.equals(kMessageListFetchBatchSize) - ..haveOldest.isFalse(); + ..haveOldest.isFalse() + ..haveNewest.isTrue(); checkLastRequest( narrow: narrow.apiEncode(), anchor: 'newest', @@ -180,7 +181,8 @@ void main() { checkNotifiedOnce(); check(model) ..messages.length.equals(30) - ..haveOldest.isTrue(); + ..haveOldest.isTrue() + ..haveNewest.isTrue(); }); test('no messages found', () async { @@ -194,7 +196,8 @@ void main() { check(model) ..fetched.isTrue() ..messages.isEmpty() - ..haveOldest.isTrue(); + ..haveOldest.isTrue() + ..haveNewest.isTrue(); }); // TODO(#824): move this test @@ -417,6 +420,10 @@ void main() { check(model).messages.length.equals(30); }); + test('while in mid-history', () async { + }, skip: true, // TODO(#82): not yet possible to exercise this case + ); + test('before fetch', () async { final stream = eg.stream(); await prepare(narrow: ChannelNarrow(stream.streamId)); @@ -2139,9 +2146,10 @@ void checkInvariants(MessageListView model) { check(model) ..messages.isEmpty() ..haveOldest.isFalse() + ..haveNewest.isFalse() ..busyFetchingMore.isFalse(); } - if (model.haveOldest) { + if (model.haveOldest && model.haveNewest) { check(model).busyFetchingMore.isFalse(); } @@ -2286,5 +2294,6 @@ extension MessageListViewChecks on Subject { Subject get middleItem => has((x) => x.middleItem, 'middleItem'); Subject get fetched => has((x) => x.fetched, 'fetched'); Subject get haveOldest => has((x) => x.haveOldest, 'haveOldest'); + Subject get haveNewest => has((x) => x.haveNewest, 'haveNewest'); Subject get busyFetchingMore => has((x) => x.busyFetchingMore, 'busyFetchingMore'); } From 77934581ceb77b223ea1128078657c4fd79361e0 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 19:15:27 -0700 Subject: [PATCH 127/290] msglist: Set haveNewest from response, like haveOldest This is NFC with a correctly-behaved server: we set `anchor=newest`, so the server always sets `found_newest` to true. Conversely, this will be helpful as we generalize `fetchInitial` to work with other anchor values; we'll use the `found_newest` value given by the server, without trying to predict it from the anchor. The server behavior that makes this effectively NFC isn't quite explicit in the API docs. Those say: found_newest: boolean Whether the server promises that the messages list includes the very newest messages matching the narrow (used by clients that paginate their requests to decide whether there may be more messages to fetch). https://zulip.com/api/get-messages#response But with `anchor=newest`, the response does need to include the very newest messages in the narrow -- that's the meaning of that `anchor` value. So the server is in fact promising the list includes those, and `found_newest` is therefore required to be true. (And indeed in practice the server does set `found_newest` to true when `anchor=newest`; it has specific logic to do so.) --- lib/model/message_list.dart | 2 +- test/example_data.dart | 20 ++++++++++++++++++ test/model/message_list_test.dart | 35 +++++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index f2415504fc..76f8402541 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -570,7 +570,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { // Now [middleMessage] is the last message (the one just added). } _haveOldest = result.foundOldest; - _haveNewest = true; // TODO(#82) + _haveNewest = result.foundNewest; _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchInitial); } diff --git a/test/example_data.dart b/test/example_data.dart index b87cbb6dc8..3dd2ab5e06 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -608,6 +608,26 @@ GetMessagesResult newestGetMessagesResult({ ); } +/// A GetMessagesResult the server might return on an initial request +/// when the anchor is in the middle of history (e.g., a /near/ link). +GetMessagesResult nearGetMessagesResult({ + required int anchor, + bool foundAnchor = true, + required bool foundOldest, + required bool foundNewest, + bool historyLimited = false, + required List messages, +}) { + return GetMessagesResult( + anchor: anchor, + foundAnchor: foundAnchor, + foundOldest: foundOldest, + foundNewest: foundNewest, + historyLimited: historyLimited, + messages: messages, + ); +} + /// A GetMessagesResult the server might return when we request older messages. GetMessagesResult olderGetMessagesResult({ required int anchor, diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 4dc4a4c1fb..05c3e58550 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -26,6 +26,7 @@ import 'recent_senders_test.dart' as recent_senders_test; import 'test_store.dart'; const newestResult = eg.newestGetMessagesResult; +const nearResult = eg.nearGetMessagesResult; const olderResult = eg.olderGetMessagesResult; void main() { @@ -185,6 +186,24 @@ void main() { ..haveNewest.isTrue(); }); + test('early in history', () async { + // For now, this gets a response that isn't realistic for the + // request it sends, to simulate when we start sending requests + // that would make this response realistic. + // TODO(#82): send appropriate fetch request + await prepare(); + connection.prepare(json: nearResult( + anchor: 1000, foundOldest: true, foundNewest: false, + messages: List.generate(111, (i) => eg.streamMessage(id: 990 + i)), + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(111) + ..haveOldest.isTrue() + ..haveNewest.isFalse(); + }); + test('no messages found', () async { await prepare(); connection.prepare(json: newestResult( @@ -421,8 +440,20 @@ void main() { }); test('while in mid-history', () async { - }, skip: true, // TODO(#82): not yet possible to exercise this case - ); + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + connection.prepare(json: nearResult( + anchor: 1000, foundOldest: true, foundNewest: false, + messages: List.generate(30, + (i) => eg.streamMessage(id: 1000 + i, stream: stream))).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + + check(model).messages.length.equals(30); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotNotified(); + check(model).messages.length.equals(30); + }); test('before fetch', () async { final stream = eg.stream(); From dcaf366e3ce86dec097fed6da8a81e90c6a3bdee Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 17 May 2025 15:49:21 -0700 Subject: [PATCH 128/290] test [nfc]: Generalize a helper eg.getMessagesResult Also expand a bit of docs to reflect what happens on a request using AnchorCode.firstUnread. --- test/example_data.dart | 59 +++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index 3dd2ab5e06..e3316e1218 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -589,25 +589,66 @@ DmMessage dmMessage({ }) as Map); } -/// A GetMessagesResult the server might return on an `anchor=newest` request. -GetMessagesResult newestGetMessagesResult({ - required bool foundOldest, +/// A GetMessagesResult the server might return for +/// a request that sent the given [anchor]. +/// +/// The request's anchor controls the response's [GetMessagesResult.anchor], +/// affects the default for [foundAnchor], +/// and in some cases forces the value of [foundOldest] or [foundNewest]. +GetMessagesResult getMessagesResult({ + required Anchor anchor, + bool? foundAnchor, + bool? foundOldest, + bool? foundNewest, bool historyLimited = false, required List messages, }) { - return GetMessagesResult( - // These anchor, foundAnchor, and foundNewest values are what the server - // appears to always return when the request had `anchor=newest`. - anchor: 10000000000000000, // that's 16 zeros - foundAnchor: false, - foundNewest: true, + final resultAnchor = switch (anchor) { + AnchorCode.oldest => 0, + NumericAnchor(:final messageId) => messageId, + AnchorCode.firstUnread => + throw ArgumentError("firstUnread not accepted in this helper; try NumericAnchor"), + AnchorCode.newest => 10_000_000_000_000_000, // that's 16 zeros + }; + switch (anchor) { + case AnchorCode.oldest || AnchorCode.newest: + assert(foundAnchor == null); + foundAnchor = false; + case AnchorCode.firstUnread || NumericAnchor(): + foundAnchor ??= true; + } + + if (anchor == AnchorCode.oldest) { + assert(foundOldest == null); + foundOldest = true; + } else if (anchor == AnchorCode.newest) { + assert(foundNewest == null); + foundNewest = true; + } + if (foundOldest == null || foundNewest == null) throw ArgumentError(); + + return GetMessagesResult( + anchor: resultAnchor, + foundAnchor: foundAnchor, foundOldest: foundOldest, + foundNewest: foundNewest, historyLimited: historyLimited, messages: messages, ); } +/// A GetMessagesResult the server might return on an `anchor=newest` request, +/// or `anchor=first_unread` when there are no unreads. +GetMessagesResult newestGetMessagesResult({ + required bool foundOldest, + bool historyLimited = false, + required List messages, +}) { + return getMessagesResult(anchor: AnchorCode.newest, foundOldest: foundOldest, + historyLimited: historyLimited, messages: messages); +} + /// A GetMessagesResult the server might return on an initial request /// when the anchor is in the middle of history (e.g., a /near/ link). GetMessagesResult nearGetMessagesResult({ From f3fb43197424d4f25b962a7d56b5f2a4c73a6697 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 17 May 2025 17:06:08 -0700 Subject: [PATCH 129/290] msglist [nfc]: Rearrange to follow normal ordering of class members In particular this causes the handful of places where each field of MessageListView needs to appear to all be next to each other. --- lib/model/message_list.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 76f8402541..716cec8b31 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -448,13 +448,19 @@ bool _sameDay(DateTime date1, DateTime date2) { /// * When the object will no longer be used, call [dispose] to free /// resources on the [PerAccountStore]. class MessageListView with ChangeNotifier, _MessageSequence { - MessageListView._({required this.store, required this.narrow}); - factory MessageListView.init( {required PerAccountStore store, required Narrow narrow}) { - final view = MessageListView._(store: store, narrow: narrow); - store.registerMessageList(view); - return view; + return MessageListView._(store: store, narrow: narrow) + .._register(); + } + + MessageListView._({required this.store, required this.narrow}); + + final PerAccountStore store; + Narrow narrow; + + void _register() { + store.registerMessageList(this); } @override @@ -463,9 +469,6 @@ class MessageListView with ChangeNotifier, _MessageSequence { super.dispose(); } - final PerAccountStore store; - Narrow narrow; - /// Whether [message] should actually appear in this message list, /// given that it does belong to the narrow. /// From 14a6695934bd949ca53fcf2f3cd59d86e4924dfd Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 8 May 2025 16:57:26 -0700 Subject: [PATCH 130/290] msglist [nfc]: Document narrow field; make setter private Even if the reader is already sure that the field doesn't get mutated from outside this file, giving it a different name from the getter is useful for seeing exactly where it does get mutated: now one can look at the references to `_narrow`, and see the mutation sites without having them intermingled with all the sites that just read it. --- lib/model/message_list.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 716cec8b31..d9f940cb80 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -454,10 +454,16 @@ class MessageListView with ChangeNotifier, _MessageSequence { .._register(); } - MessageListView._({required this.store, required this.narrow}); + MessageListView._({required this.store, required Narrow narrow}) + : _narrow = narrow; final PerAccountStore store; - Narrow narrow; + + /// The narrow shown in this message list. + /// + /// This can change over time, notably if showing a topic that gets moved. + Narrow get narrow => _narrow; + Narrow _narrow; void _register() { store.registerMessageList(this); @@ -601,9 +607,9 @@ class MessageListView with ChangeNotifier, _MessageSequence { // 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(); + _narrow = narrow.sansWith(); case StreamMessage(): - this.narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull); + _narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull); case DmMessage(): // TODO(log) assert(false); } @@ -786,7 +792,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { switch (propagateMode) { case PropagateMode.changeAll: case PropagateMode.changeLater: - narrow = newNarrow; + _narrow = newNarrow; _reset(); fetchInitial(); case PropagateMode.changeOne: From 44b49be72d982f613f5ad42ca1ea7b3e90ef0b3e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 8 May 2025 16:27:04 -0700 Subject: [PATCH 131/290] msglist: Send positive numAfter for fetchInitial This is effectively NFC given normal server behavior. In particular, the Zulip server is smart enough to skip doing any actual work to fetch later messages when the anchor is already `newest`. When we start passing anchors other than `newest`, we'll need this. --- lib/model/message_list.dart | 2 +- test/model/message_list_test.dart | 2 +- test/widgets/message_list_test.dart | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index d9f940cb80..e1334ca060 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -561,7 +561,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { narrow: narrow.apiEncode(), anchor: AnchorCode.newest, numBefore: kMessageListFetchBatchSize, - numAfter: 0, + numAfter: kMessageListFetchBatchSize, allowEmptyTopicName: true, ); if (this.generation > generation) return; diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 05c3e58550..79b5ddb180 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -157,7 +157,7 @@ void main() { narrow: narrow.apiEncode(), anchor: 'newest', numBefore: kMessageListFetchBatchSize, - numAfter: 0, + numAfter: kMessageListFetchBatchSize, allowEmptyTopicName: true, ); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 81cc384ef8..3b3b01b323 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -404,7 +404,7 @@ void main() { 'narrow': jsonEncode(narrow.apiEncode()), 'anchor': AnchorCode.newest.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), - 'num_after': '0', + 'num_after': kMessageListFetchBatchSize.toString(), 'allow_empty_topic_name': 'true', }); }); @@ -437,7 +437,7 @@ void main() { 'narrow': jsonEncode(narrow.apiEncode()), 'anchor': AnchorCode.newest.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), - 'num_after': '0', + 'num_after': kMessageListFetchBatchSize.toString(), 'allow_empty_topic_name': 'true', }); }); From ce98562167fe8b50c8063beab626882a69e86afc Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 8 May 2025 16:38:13 -0700 Subject: [PATCH 132/290] msglist: Make initial fetch from any anchor, in model This is NFC as to the live app, because we continue to always set the anchor to AnchorCode.newest there. --- lib/model/message_list.dart | 50 ++++++++++++----- lib/widgets/message_list.dart | 6 ++- test/model/message_list_test.dart | 90 ++++++++++++++++++++++++------- 3 files changed, 112 insertions(+), 34 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index e1334ca060..6b57fe39b1 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -14,6 +14,8 @@ import 'message.dart'; import 'narrow.dart'; import 'store.dart'; +export '../api/route/messages.dart' show Anchor, AnchorCode, NumericAnchor; + /// The number of messages to fetch in each request. const kMessageListFetchBatchSize = 100; // TODO tune @@ -127,9 +129,6 @@ mixin _MessageSequence { /// Whether we know we have the newest messages for this narrow. /// - /// (Currently this is always true once [fetched] is true, - /// because we start from the newest.) - /// /// See also [haveOldest]. bool get haveNewest => _haveNewest; bool _haveNewest = false; @@ -448,14 +447,20 @@ bool _sameDay(DateTime date1, DateTime date2) { /// * When the object will no longer be used, call [dispose] to free /// resources on the [PerAccountStore]. class MessageListView with ChangeNotifier, _MessageSequence { - factory MessageListView.init( - {required PerAccountStore store, required Narrow narrow}) { - return MessageListView._(store: store, narrow: narrow) + factory MessageListView.init({ + required PerAccountStore store, + required Narrow narrow, + Anchor anchor = AnchorCode.newest, // TODO(#82): make required, for explicitness + }) { + return MessageListView._(store: store, narrow: narrow, anchor: anchor) .._register(); } - MessageListView._({required this.store, required Narrow narrow}) - : _narrow = narrow; + MessageListView._({ + required this.store, + required Narrow narrow, + required Anchor anchor, + }) : _narrow = narrow, _anchor = anchor; final PerAccountStore store; @@ -465,6 +470,17 @@ class MessageListView with ChangeNotifier, _MessageSequence { Narrow get narrow => _narrow; Narrow _narrow; + /// The anchor point this message list starts from in the message history. + /// + /// This is passed to the server in the get-messages request + /// sent by [fetchInitial]. + /// That includes not only the original [fetchInitial] call made by + /// the message-list widget, but any additional [fetchInitial] calls + /// which might be made internally by this class in order to + /// fetch the messages from scratch, e.g. after certain events. + Anchor get anchor => _anchor; + final Anchor _anchor; + void _register() { store.registerMessageList(this); } @@ -550,8 +566,6 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// Fetch messages, starting from scratch. Future fetchInitial() async { - // TODO(#80): fetch from anchor firstUnread, instead of newest - // TODO(#82): fetch from a given message ID as anchor assert(!fetched && !haveOldest && !haveNewest && !busyFetchingMore); assert(messages.isEmpty && contents.isEmpty); _setStatus(FetchingStatus.fetchInitial, was: FetchingStatus.unstarted); @@ -559,7 +573,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { final generation = this.generation; final result = await getMessages(store.connection, narrow: narrow.apiEncode(), - anchor: AnchorCode.newest, + anchor: anchor, numBefore: kMessageListFetchBatchSize, numAfter: kMessageListFetchBatchSize, allowEmptyTopicName: true, @@ -571,12 +585,20 @@ class MessageListView with ChangeNotifier, _MessageSequence { store.reconcileMessages(result.messages); store.recentSenders.handleMessages(result.messages); // TODO(#824) - // We'll make the bottom slice start at the last visible message, if any. + // The bottom slice will start at the "anchor message". + // This is the first visible message at or past [anchor] if any, + // else the last visible message if any. [reachedAnchor] helps track that. + bool reachedAnchor = false; for (final message in result.messages) { if (!_messageVisible(message)) continue; - middleMessage = messages.length; + if (!reachedAnchor) { + // Push the previous message into the top slice. + middleMessage = messages.length; + // We could interpret [anchor] for ourselves; but the server has already + // done that work, reducing it to an int, `result.anchor`. So use that. + reachedAnchor = message.id >= result.anchor; + } _addMessage(message); - // Now [middleMessage] is the last message (the one just added). } _haveOldest = result.foundOldest; _haveNewest = result.foundNewest; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index d0862cb1d7..542d0a52b2 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -517,7 +517,11 @@ class _MessageListState extends State with PerAccountStoreAwareStat } void _initModel(PerAccountStore store) { - _model = MessageListView.init(store: store, narrow: widget.narrow); + // TODO(#82): get anchor as page/route argument, instead of using newest + // TODO(#80): default to anchor firstUnread, instead of newest + final anchor = AnchorCode.newest; + _model = MessageListView.init(store: store, + narrow: widget.narrow, anchor: anchor); model.addListener(_modelChanged); model.fetchInitial(); } diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 79b5ddb180..c5d1a65c2e 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -67,7 +67,10 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [model] and the rest of the test state. - Future prepare({Narrow narrow = const CombinedFeedNarrow()}) async { + Future prepare({ + Narrow narrow = const CombinedFeedNarrow(), + Anchor anchor = AnchorCode.newest, + }) async { final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); store = eg.store(); @@ -75,7 +78,7 @@ void main() { await store.addSubscription(subscription); connection = store.connection as FakeApiConnection; notifiedCount = 0; - model = MessageListView.init(store: store, narrow: narrow) + model = MessageListView.init(store: store, narrow: narrow, anchor: anchor) ..addListener(() { checkInvariants(model); notifiedCount++; @@ -88,11 +91,18 @@ void main() { /// /// The test case must have already called [prepare] to initialize the state. Future prepareMessages({ - required bool foundOldest, + bool? foundOldest, + bool? foundNewest, + int? anchorMessageId, required List messages, }) async { - connection.prepare(json: - newestResult(foundOldest: foundOldest, messages: messages).toJson()); + final result = eg.getMessagesResult( + anchor: model.anchor == AnchorCode.firstUnread + ? NumericAnchor(anchorMessageId!) : model.anchor, + foundOldest: foundOldest, + foundNewest: foundNewest, + messages: messages); + connection.prepare(json: result.toJson()); await model.fetchInitial(); checkNotifiedOnce(); } @@ -187,11 +197,7 @@ void main() { }); test('early in history', () async { - // For now, this gets a response that isn't realistic for the - // request it sends, to simulate when we start sending requests - // that would make this response realistic. - // TODO(#82): send appropriate fetch request - await prepare(); + await prepare(anchor: NumericAnchor(1000)); connection.prepare(json: nearResult( anchor: 1000, foundOldest: true, foundNewest: false, messages: List.generate(111, (i) => eg.streamMessage(id: 990 + i)), @@ -219,6 +225,26 @@ void main() { ..haveNewest.isTrue(); }); + group('sends proper anchor', () { + Future checkFetchWithAnchor(Anchor anchor) async { + await prepare(anchor: anchor); + // This prepared response isn't entirely realistic, depending on the anchor. + // That's OK; these particular tests don't use the details of the response. + connection.prepare(json: + newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(connection.lastRequest).isA() + .url.queryParameters['anchor'] + .equals(anchor.toJson()); + } + + test('oldest', () => checkFetchWithAnchor(AnchorCode.oldest)); + test('firstUnread', () => checkFetchWithAnchor(AnchorCode.firstUnread)); + test('newest', () => checkFetchWithAnchor(AnchorCode.newest)); + test('numeric', () => checkFetchWithAnchor(NumericAnchor(12345))); + }); + // TODO(#824): move this test test('recent senders track all the messages', () async { const narrow = CombinedFeedNarrow(); @@ -441,13 +467,10 @@ void main() { test('while in mid-history', () async { final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - connection.prepare(json: nearResult( - anchor: 1000, foundOldest: true, foundNewest: false, - messages: List.generate(30, - (i) => eg.streamMessage(id: 1000 + i, stream: stream))).toJson()); - await model.fetchInitial(); - checkNotifiedOnce(); + await prepare(narrow: ChannelNarrow(stream.streamId), + anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, messages: + List.generate(30, (i) => eg.streamMessage(id: 1000 + i, stream: stream))); check(model).messages.length.equals(30); await store.addMessage(eg.streamMessage(stream: stream)); @@ -1711,8 +1734,9 @@ void main() { ..middleMessage.equals(0); }); - test('on fetchInitial not empty', () async { - await prepare(narrow: const CombinedFeedNarrow()); + test('on fetchInitial, anchor past end', () async { + await prepare(narrow: const CombinedFeedNarrow(), + anchor: AnchorCode.newest); final stream1 = eg.stream(); final stream2 = eg.stream(); await store.addStreams([stream1, stream2]); @@ -1735,6 +1759,34 @@ void main() { .equals(messages[messages.length - 2].id); }); + test('on fetchInitial, anchor in middle', () async { + final s1 = eg.stream(); + final s2 = eg.stream(); + final messages = [ + eg.streamMessage(id: 1, stream: s1), eg.streamMessage(id: 2, stream: s2), + eg.streamMessage(id: 3, stream: s1), eg.streamMessage(id: 4, stream: s2), + eg.streamMessage(id: 5, stream: s1), eg.streamMessage(id: 6, stream: s2), + eg.streamMessage(id: 7, stream: s1), eg.streamMessage(id: 8, stream: s2), + ]; + final anchorId = 4; + + await prepare(narrow: const CombinedFeedNarrow(), + anchor: NumericAnchor(anchorId)); + await store.addStreams([s1, s2]); + await store.addSubscription(eg.subscription(s1)); + await store.addSubscription(eg.subscription(s2, isMuted: true)); + await prepareMessages(foundOldest: true, foundNewest: true, + messages: messages); + // The anchor message is the first visible message with ID at least anchorId… + check(model) + ..messages[model.middleMessage - 1].id.isLessThan(anchorId) + ..messages[model.middleMessage].id.isGreaterOrEqual(anchorId); + // … even though a non-visible message actually had anchorId itself. + check(messages[3].id) + ..equals(anchorId) + ..isLessThan(model.messages[model.middleMessage].id); + }); + /// Like [prepareMessages], but arrange for the given top and bottom slices. Future prepareMessageSplit(List top, List bottom, { bool foundOldest = true, From 4fc68620841d99e0a3d89b9b905524ce494015a5 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 17 May 2025 12:47:04 -0700 Subject: [PATCH 133/290] msglist [nfc]: Cut default for MessageListView.anchor There's no value that's a natural default for this at a model level: different UI scenarios will use different values. So require callers to be explicit. --- lib/model/message_list.dart | 2 +- test/model/message_list_test.dart | 9 ++++++--- test/model/message_test.dart | 5 +++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 6b57fe39b1..349a0f8dd5 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -450,7 +450,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { factory MessageListView.init({ required PerAccountStore store, required Narrow narrow, - Anchor anchor = AnchorCode.newest, // TODO(#82): make required, for explicitness + required Anchor anchor, }) { return MessageListView._(store: store, narrow: narrow, anchor: anchor) .._register(); diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index c5d1a65c2e..cad01baf7e 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1381,12 +1381,14 @@ void main() { int notifiedCount1 = 0; final model1 = MessageListView.init(store: store, - narrow: ChannelNarrow(stream.streamId)) + narrow: ChannelNarrow(stream.streamId), + anchor: AnchorCode.newest) ..addListener(() => notifiedCount1++); int notifiedCount2 = 0; final model2 = MessageListView.init(store: store, - narrow: eg.topicNarrow(stream.streamId, 'hello')) + narrow: eg.topicNarrow(stream.streamId, 'hello'), + anchor: AnchorCode.newest) ..addListener(() => notifiedCount2++); for (final m in [model1, model2]) { @@ -1426,7 +1428,8 @@ void main() { await store.handleEvent(mkEvent(message)); // init msglist *after* event was handled - model = MessageListView.init(store: store, narrow: const CombinedFeedNarrow()); + model = MessageListView.init(store: store, + narrow: const CombinedFeedNarrow(), anchor: AnchorCode.newest); checkInvariants(model); connection.prepare(json: diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 7dff077b1d..0d28ed7dc0 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -51,7 +51,6 @@ void main() { /// Initialize [store] and the rest of the test state. Future prepare({ - Narrow narrow = const CombinedFeedNarrow(), ZulipStream? stream, int? zulipFeatureLevel, }) async { @@ -64,7 +63,9 @@ void main() { await store.addSubscription(subscription); connection = store.connection as FakeApiConnection; notifiedCount = 0; - messageList = MessageListView.init(store: store, narrow: narrow) + messageList = MessageListView.init(store: store, + narrow: const CombinedFeedNarrow(), + anchor: AnchorCode.newest) ..addListener(() { notifiedCount++; }); From 21d0d5e0d8276249009330309ac2ae1387778d8c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 17 May 2025 12:18:11 -0700 Subject: [PATCH 134/290] msglist test [nfc]: Simplify a bit by cutting redundant default narrow --- test/model/message_list_test.dart | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index cad01baf7e..80be2235c3 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -322,8 +322,7 @@ void main() { }); test('nop when already fetching', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); + await prepare(); await prepareMessages(foundOldest: false, messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); @@ -351,7 +350,7 @@ void main() { }); test('nop when already haveOldest true', () async { - await prepare(narrow: const CombinedFeedNarrow()); + await prepare(); await prepareMessages(foundOldest: true, messages: List.generate(30, (i) => eg.streamMessage())); check(model) @@ -370,7 +369,7 @@ void main() { 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 prepare(); await prepareMessages(foundOldest: false, messages: initialMessages); check(connection.takeRequests()).single; @@ -400,8 +399,7 @@ void main() { })); test('handles servers not understanding includeAnchor', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); + await prepare(); await prepareMessages(foundOldest: false, messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); @@ -419,8 +417,7 @@ void main() { // TODO(#824): move this test test('recent senders track all the messages', () async { - const narrow = CombinedFeedNarrow(); - await prepare(narrow: narrow); + await prepare(); final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); await prepareMessages(foundOldest: false, messages: initialMessages); From 2f69e7c2025945c9061b1bc2f8f567d686a80d0f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 19:49:35 -0700 Subject: [PATCH 135/290] msglist [nfc]: Factor out _fetchMore from fetchOlder --- lib/model/message_list.dart | 53 ++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 349a0f8dd5..f2872170cf 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -649,10 +649,39 @@ class MessageListView with ChangeNotifier, _MessageSequence { if (haveOldest) return; if (busyFetchingMore) return; assert(fetched); + assert(messages.isNotEmpty); + await _fetchMore( + anchor: NumericAnchor(messages[0].id), + numBefore: kMessageListFetchBatchSize, + numAfter: 0, + processResult: (result) { + if (result.messages.isNotEmpty + && result.messages.last.id == messages[0].id) { + // TODO(server-6): includeAnchor should make this impossible + result.messages.removeLast(); + } + + store.reconcileMessages(result.messages); + store.recentSenders.handleMessages(result.messages); // TODO(#824) + + final fetchedMessages = _allMessagesVisible + ? result.messages // Avoid unnecessarily copying the list. + : result.messages.where(_messageVisible); + + _insertAllMessages(0, fetchedMessages); + _haveOldest = result.foundOldest; + }); + } + + Future _fetchMore({ + required Anchor anchor, + required int numBefore, + required int numAfter, + required void Function(GetMessagesResult) processResult, + }) async { assert(narrow is! TopicNarrow // We only intend to send "with" in [fetchInitial]; see there. || (narrow as TopicNarrow).with_ == null); - assert(messages.isNotEmpty); _setStatus(FetchingStatus.fetchingMore, was: FetchingStatus.idle); final generation = this.generation; bool hasFetchError = false; @@ -661,10 +690,10 @@ class MessageListView with ChangeNotifier, _MessageSequence { try { result = await getMessages(store.connection, narrow: narrow.apiEncode(), - anchor: NumericAnchor(messages[0].id), + anchor: anchor, includeAnchor: false, - numBefore: kMessageListFetchBatchSize, - numAfter: 0, + numBefore: numBefore, + numAfter: numAfter, allowEmptyTopicName: true, ); } catch (e) { @@ -673,21 +702,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { } if (this.generation > generation) return; - if (result.messages.isNotEmpty - && result.messages.last.id == messages[0].id) { - // TODO(server-6): includeAnchor should make this impossible - result.messages.removeLast(); - } - - store.reconcileMessages(result.messages); - store.recentSenders.handleMessages(result.messages); // TODO(#824) - - final fetchedMessages = _allMessagesVisible - ? result.messages // Avoid unnecessarily copying the list. - : result.messages.where(_messageVisible); - - _insertAllMessages(0, fetchedMessages); - _haveOldest = result.foundOldest; + processResult(result); } finally { if (this.generation == generation) { if (hasFetchError) { From 7820fd9bbfc7a1021d2fb548012377ed3943fada Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 19:55:01 -0700 Subject: [PATCH 136/290] msglist: Add fetchNewer method to model This completes the model layer of #82 and #80: the message list can start at an arbitrary anchor, including a numeric message-ID anchor or AnchorCode.firstUnread, and can fetch more history from there in both directions. Still to do is to work that into the widgets layer. This change is therefore NFC as to the live app: nothing calls this method yet. --- lib/model/message_list.dart | 40 ++++++- test/example_data.dart | 18 ++++ test/model/message_list_test.dart | 174 ++++++++++++++++++++++++++---- 3 files changed, 208 insertions(+), 24 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index f2872170cf..2617f18b68 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -78,7 +78,7 @@ enum FetchingStatus { /// and has no outstanding requests or backoff. idle, - /// The model has an active `fetchOlder` request. + /// The model has an active `fetchOlder` or `fetchNewer` request. fetchingMore, /// The model is in a backoff period from a failed request. @@ -673,6 +673,42 @@ class MessageListView with ChangeNotifier, _MessageSequence { }); } + /// Fetch the next batch of newer messages, if applicable. + /// + /// If there are no newer messages to fetch (i.e. if [haveNewest]), + /// or if this message list is already busy fetching more messages + /// (i.e. if [busyFetchingMore], which includes backoff from failed requests), + /// then this method does nothing and immediately returns. + /// That makes this method suitable to call frequently, e.g. every frame, + /// whenever it looks likely to be useful to have more messages. + Future fetchNewer() async { + if (haveNewest) return; + if (busyFetchingMore) return; + assert(fetched); + assert(messages.isNotEmpty); + await _fetchMore( + anchor: NumericAnchor(messages.last.id), + numBefore: 0, + numAfter: kMessageListFetchBatchSize, + processResult: (result) { + if (result.messages.isNotEmpty + && result.messages.first.id == messages.last.id) { + // TODO(server-6): includeAnchor should make this impossible + result.messages.removeAt(0); + } + + store.reconcileMessages(result.messages); + store.recentSenders.handleMessages(result.messages); // TODO(#824) + + for (final message in result.messages) { + if (_messageVisible(message)) { + _addMessage(message); + } + } + _haveNewest = result.foundNewest; + }); + } + Future _fetchMore({ required Anchor anchor, required int numBefore, @@ -775,7 +811,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { // This message list's [messages] doesn't yet reach the new end // of the narrow's message history. (Either [fetchInitial] hasn't yet // completed, or if it has then it was in the middle of history and no - // subsequent fetch has reached the end.) + // subsequent [fetchNewer] has reached the end.) // So this still-newer message doesn't belong. // Leave it to be found by a subsequent fetch when appropriate. // TODO mitigate this fetch/event race: save message to add to list later, diff --git a/test/example_data.dart b/test/example_data.dart index e3316e1218..79b92bdda8 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -687,6 +687,24 @@ GetMessagesResult olderGetMessagesResult({ ); } +/// A GetMessagesResult the server might return when we request newer messages. +GetMessagesResult newerGetMessagesResult({ + required int anchor, + bool foundAnchor = false, // the value if the server understood includeAnchor false + required bool foundNewest, + bool historyLimited = false, + required List messages, +}) { + return GetMessagesResult( + anchor: anchor, + foundAnchor: foundAnchor, + foundOldest: false, + foundNewest: foundNewest, + historyLimited: historyLimited, + messages: messages, + ); +} + int _nextLocalMessageId = 1; StreamOutboxMessage streamOutboxMessage({ diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 80be2235c3..d58de664d8 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -28,6 +28,7 @@ import 'test_store.dart'; const newestResult = eg.newestGetMessagesResult; const nearResult = eg.nearGetMessagesResult; const olderResult = eg.olderGetMessagesResult; +const newerResult = eg.newerGetMessagesResult; void main() { // Arrange for errors caught within the Flutter framework to be printed @@ -291,8 +292,8 @@ void main() { }); }); - group('fetchOlder', () { - test('smoke', () async { + group('fetching more', () { + test('fetchOlder smoke', () async { const narrow = CombinedFeedNarrow(); await prepare(narrow: narrow); await prepareMessages(foundOldest: false, @@ -321,14 +322,43 @@ void main() { ); }); - test('nop when already fetching', () async { - await prepare(); - await prepareMessages(foundOldest: false, + test('fetchNewer smoke', () async { + const narrow = CombinedFeedNarrow(); + await prepare(narrow: narrow, anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); + connection.prepare(json: newerResult( + anchor: 1099, foundNewest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1100 + i)), + ).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + check(model).busyFetchingMore.isTrue(); + + await fetchFuture; + checkNotifiedOnce(); + check(model) + ..busyFetchingMore.isFalse() + ..messages.length.equals(200); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: '1099', + includeAnchor: false, + numBefore: 0, + numAfter: kMessageListFetchBatchSize, + allowEmptyTopicName: true, + ); + }); + + test('nop when already fetching older', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: List.generate(201, (i) => eg.streamMessage(id: 900 + i))); + connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 900 + i)), + anchor: 900, foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 800 + i)), ).toJson()); final fetchFuture = model.fetchOlder(); checkNotifiedOnce(); @@ -338,24 +368,56 @@ void main() { final fetchFuture2 = model.fetchOlder(); checkNotNotified(); check(model).busyFetchingMore.isTrue(); + final fetchFuture3 = model.fetchNewer(); + checkNotNotified(); + check(model)..busyFetchingMore.isTrue()..messages.length.equals(201); await fetchFuture; await fetchFuture2; + await fetchFuture3; // We must not have made another request, because we didn't // prepare another response and didn't get an exception. checkNotifiedOnce(); - check(model) - ..busyFetchingMore.isFalse() - ..messages.length.equals(200); + check(model)..busyFetchingMore.isFalse()..messages.length.equals(301); }); - test('nop when already haveOldest true', () async { - await prepare(); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage())); + test('nop when already fetching newer', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: List.generate(201, (i) => eg.streamMessage(id: 900 + i))); + + connection.prepare(json: newerResult( + anchor: 1100, foundNewest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1101 + i)), + ).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + check(model).busyFetchingMore.isTrue(); + + // Don't prepare another response. + final fetchFuture2 = model.fetchOlder(); + checkNotNotified(); + check(model).busyFetchingMore.isTrue(); + final fetchFuture3 = model.fetchNewer(); + checkNotNotified(); + check(model)..busyFetchingMore.isTrue()..messages.length.equals(201); + + await fetchFuture; + await fetchFuture2; + await fetchFuture3; + // We must not have made another request, because we didn't + // prepare another response and didn't get an exception. + checkNotifiedOnce(); + check(model)..busyFetchingMore.isFalse()..messages.length.equals(301); + }); + + test('fetchOlder nop when already haveOldest true', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, messages: + List.generate(151, (i) => eg.streamMessage(id: 950 + i))); check(model) ..haveOldest.isTrue() - ..messages.length.equals(30); + ..messages.length.equals(151); await model.fetchOlder(); // We must not have made a request, because we didn't @@ -363,14 +425,33 @@ void main() { checkNotNotified(); check(model) ..haveOldest.isTrue() - ..messages.length.equals(30); + ..messages.length.equals(151); + }); + + test('fetchNewer nop when already haveNewest true', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: false, foundNewest: true, messages: + List.generate(151, (i) => eg.streamMessage(id: 950 + i))); + check(model) + ..haveNewest.isTrue() + ..messages.length.equals(151); + + await model.fetchNewer(); + // We must not have made a request, because we didn't + // prepare a response and didn't get an exception. + checkNotNotified(); + check(model) + ..haveNewest.isTrue() + ..messages.length.equals(151); }); 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(); - await prepareMessages(foundOldest: false, messages: initialMessages); + final newerMessages = List.generate(5, (i) => eg.streamMessage()); + await prepare(anchor: NumericAnchor(initialMessages[2].id)); + await prepareMessages(foundOldest: false, foundNewest: false, + messages: initialMessages); check(connection.takeRequests()).single; connection.prepare(apiException: eg.apiBadRequest()); @@ -385,20 +466,31 @@ void main() { check(model).busyFetchingMore.isTrue(); check(connection.lastRequest).isNull(); + await model.fetchNewer(); + checkNotNotified(); + check(model).busyFetchingMore.isTrue(); + check(connection.lastRequest).isNull(); + // Wait long enough that a first backoff is sure to finish. async.elapse(const Duration(seconds: 1)); check(model).busyFetchingMore.isFalse(); checkNotifiedOnce(); check(connection.lastRequest).isNull(); - connection.prepare(json: olderResult( - anchor: 1000, foundOldest: false, messages: olderMessages).toJson()); + connection.prepare(json: olderResult(anchor: initialMessages.first.id, + foundOldest: false, messages: olderMessages).toJson()); await model.fetchOlder(); checkNotified(count: 2); check(connection.takeRequests()).single; + + connection.prepare(json: newerResult(anchor: initialMessages.last.id, + foundNewest: false, messages: newerMessages).toJson()); + await model.fetchNewer(); + checkNotified(count: 2); + check(connection.takeRequests()).single; })); - test('handles servers not understanding includeAnchor', () async { + test('fetchOlder handles servers not understanding includeAnchor', () async { await prepare(); await prepareMessages(foundOldest: false, messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i))); @@ -415,8 +507,25 @@ void main() { ..messages.length.equals(200); }); + test('fetchNewer handles servers not understanding includeAnchor', () async { + await prepare(anchor: NumericAnchor(1000)); + await prepareMessages(foundOldest: true, foundNewest: false, + messages: List.generate(101, (i) => eg.streamMessage(id: 1000 + i))); + + // The old behavior is to include the anchor message regardless of includeAnchor. + connection.prepare(json: newerResult( + anchor: 1100, foundNewest: false, foundAnchor: true, + messages: List.generate(101, (i) => eg.streamMessage(id: 1100 + i)), + ).toJson()); + await model.fetchNewer(); + checkNotified(count: 2); + check(model) + ..busyFetchingMore.isFalse() + ..messages.length.equals(201); + }); + // TODO(#824): move this test - test('recent senders track all the messages', () async { + test('fetchOlder recent senders track all the messages', () async { await prepare(); final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); await prepareMessages(foundOldest: false, messages: initialMessages); @@ -434,6 +543,27 @@ void main() { recent_senders_test.checkMatchesMessages(store.recentSenders, [...initialMessages, ...oldMessages]); }); + + // TODO(#824): move this test + test('TODO fetchNewer recent senders track all the messages', () async { + await prepare(anchor: NumericAnchor(100)); + final initialMessages = List.generate(10, (i) => eg.streamMessage(id: 100 + i)); + await prepareMessages(foundOldest: true, foundNewest: false, + messages: initialMessages); + + final newMessages = List.generate(10, (i) => eg.streamMessage(id: 110 + i)) + // Not subscribed to the stream with id 10. + ..add(eg.streamMessage(id: 120, stream: eg.stream(streamId: 10))); + connection.prepare(json: newerResult( + anchor: 100, foundNewest: false, + messages: newMessages, + ).toJson()); + await model.fetchNewer(); + + check(model).messages.length.equals(20); + recent_senders_test.checkMatchesMessages(store.recentSenders, + [...initialMessages, ...newMessages]); + }); }); group('MessageEvent', () { From 1ecd491181a7f55a4b19116997efcc9b3c55a53a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 4 Jun 2025 15:12:13 -0700 Subject: [PATCH 137/290] recent dms: Let last item scroll 90px up from bottom to make room for a FAB We're about to add a "New DM" FAB, and this will ensure that the user can scoot the last few recent-DM items out from under that FAB. --- lib/widgets/recent_dm_conversations.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 9c899cc146..1fe9118635 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -62,6 +62,7 @@ class _RecentDmConversationsPageBodyState extends State Date: Wed, 4 Jun 2025 15:55:39 -0700 Subject: [PATCH 138/290] icons: Add "plus" icon, from Figma Taken from Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4912-31325&m=dev --- assets/icons/ZulipIcons.ttf | Bin 14384 -> 14520 bytes assets/icons/plus.svg | 3 +++ lib/widgets/icons.dart | 29 ++++++++++++++++------------- 3 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 assets/icons/plus.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 0ef0c3f461d17e792be725867eb5298a77c384c8..06ae4b4057758665969642552b60c3804ba93475 100644 GIT binary patch delta 1878 zcmb7FOK4nG82--OnVXqprpe@G($<()(`x3Cxp(eNlF2-hOwx))gOw?BOz*yex^N*F%%(_%QVP}zA})d((S-lvWN!wY&W&6w2>|*vUD54BXlxJQC#&^%k+}; zB10w^D2FhuWocP@%HCU0M}nhFEyBmLXhhD95L-Fw}VVq~BO z5l+(?=DmVzHpvOOloZ?!O{O^*nt)5Uyy?7qZZUKm1qKbW2ub_iK** zWl4LURf;PaCB#DH#SFRgpu^U_X9~r9%btP>QmfH z%@NgYk(c3REICDTY#$?%-0ux(J-S(_$wFNVgJgKAl2r?)mQ?TmtoD9V85WSG`5+wt zujdu#s4p)f&N<&*XVd3(T6H_VLtpv%m4hl*5z0!w9CjG{4)>Mh!^7tSE@Aya#Q9S1 zNFrFBALX1vKjo!8*lY`UQ6Wz`uaKl%P{>g(Dwvc@3USJ^!XV{kg<;Ao3NgxM zg$yNM@h;Mot0a(LaPRIar9^>vNuh^wO+hlWt}wLqNkI2hJ|N!`h&Z>a3eN`L4y8hq zhyJX7wR*c|rsjUwAO51PyU@Dj(XKptNV`>k?57(3%lvJ<;Sndd TUQY#t&s&~fU3NCo?+E+@#as+v delta 1707 zcmb7^OKcle6o$|EF-e@4^P-`Jk~S$V#2$Ob^K1-RRuL7R7jATG><~uxHN=B zNSHP1rW&zBut8lE7C=~3qM%4rNNf-ih*cL|0P3O(hzc9Ts}Y%DeGQDP)YmuPf8wt{@ZBUw z|LWR8srJe5pP_5t;dQZI+iV!m899_H;n2BSeR1Gw*%8USClbmx)-J67YR~^ElD)zH z83q)QIVW%*KW$#2GpaoTv>*fDlPtD!q#37?Xv zbciL7$@8)sP#10a=1ai@qvGXX*ltH;)TG2a{QiTiX_v2?_IT1wY zVrS!%3+#tzrbC(*=}VZaKBjQASh>1S1;++(5r+@ z(?LmkuvVpw6HN7fQ2H2fg4AlbDtH3DO5ZPS_vCk{*+S>X@I4{VN_|NhRHsfSL6W;C zqt4crVVsMBT=knLp(lqZ#>G0vUX9vgGSPchBdRAf%72J9Tx@JP!YYXG{~R7@stHU6 z4LL;J`$pP=>8gXB2;?r2EG%8*t*o3N#u#spNt)DELYIVFg9`rfY}cbvQ98-1&Y=<< zzsc9h={6C@TqfsZ( zPh^u)OIkLH)j`d;28h$ zU4rbekI`v}XI{?A)-R*}Pv)?l#%i2`ZIWbZqR8|on16{PRPR%y_9(B)BvUk9@x7L3 z2yglovvvUQ@iE)Y7K(UfN{&^YN_x5KWNNzNA8Ot7pEA3f9b}epS-ts9gL#cKT+>Lv zFKCRz3mRka84Vj=)NtV?jV!#Z!G$U-8b$bQQ@ubQyreM!uWDrAx<(1+mJPsVC~F!P z+|Ve)=QXC_mo<{`1q}~g*C=tz$}1XGctc|nepMp{b5#dW6WY?KY=75ghWsDe%f~xo zow=^ByXU&E#J%`C@f$stdv<%5`uh7m`2WxLddF9T+ixAY73UUzJ@@8zEZ=7MgN1kh E2GU;b*8l(j diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 0000000000..a5b1b7e078 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index be088afd48..360cbd6d4e 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -114,44 +114,47 @@ abstract final class ZulipIcons { /// The Zulip custom icon "mute". static const IconData mute = IconData(0xf11e, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "plus". + static const IconData plus = IconData(0xf11f, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf12c, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From 5d9cf6ab4ef2723cbe8366c594c47e40168e1a78 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 6 Jun 2025 11:48:50 -0700 Subject: [PATCH 139/290] icons: Add checked_circle_{,un}checked from Figma, with modifications (The _checked/_unchecked suffix is added; they're both called "checked_circle" in Figma.) When I use the SVGs from Figma without modifications, these look wrong; seems like the filled/unfilled areas are inverted, and the background is a solid square instead of transparent. Figma link: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=62-8121&m=dev So I deleted some goo that looks like it's not needed anyway. Here are the diffs: ``` @@ -1,10 +1,3 @@ - - - - - - - ``` ``` @@ -1,10 +1,3 @@ - - - - - - - ``` --- assets/icons/ZulipIcons.ttf | Bin 14520 -> 14968 bytes assets/icons/check_circle_checked.svg | 3 + assets/icons/check_circle_unchecked.svg | 3 + lib/widgets/icons.dart | 78 +++++++++++++----------- 4 files changed, 48 insertions(+), 36 deletions(-) create mode 100644 assets/icons/check_circle_checked.svg create mode 100644 assets/icons/check_circle_unchecked.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 06ae4b4057758665969642552b60c3804ba93475..5df791a6c94a92bc57f4d26323f8f5550914fe91 100644 GIT binary patch delta 2290 zcmah~OKcNY6ur;mUmOP~!59NA35Fzm;`lo=juShM^Cf~5K|z2*9Ag4O!Xn+E3eheQ3pPb6rIlJCL@TIRqzh=z9gmZyqUwx}?|tw7 z?|t)p>-hX+fdmni(;1Q|*t2VQI`iky6Cx2KGWWl;D-hmuf7f#&%Qn=SDiC%53I8ykTT(p5crMvW$=0&|YD6Way;-zU^+9?f6 zUr7(hLQZO;ZW^RXGEpT3@rEc&5mHE{7&U_nQk+_-6}*HdEov<8Td?1ZH$X|~MPL_a zd+w{_9|lp~wM=HR>5db+V;Ndz@=*l>1}LfrRN+X4v=e#>BxWTqtsy()GEzx{wm|+C z>_X6N*F98X6@^W+u3t;lRB%EZq!iS-j&2x+z^POPhaqIB;5Uq_> z87&GKb-59qo1TWt2r5X?a%gjEZLs9p*U}0!IDpXHTy`FSoWpylY+=9DO&i9z5t{u} z*iVNkPn-1V1Q4V&8J=wvS(ibL_qpCU0w!=;>{#(E<4CdvJ9S8=q}2dE1qs`)M;R(~ zq6lWzQwqYL8)b~rIr^Ax(Cq@q*?SiF7dv5V)k1XE@=HZB+KTsQUji3(PLh}f82Wtm~raBR@Sr#y|u#Yajr6&_Ek#&Oj2BZ5dHu z*nkRp*Z{72I%1#&bi_a_=+OfI0CC`$fp*YQ1GuVb%s>is+<*)^VITyWH^AdKZlD|V zgn=OFNdpPcNdt*yFsBTpL8lCKfWBt{{|_{6fIBo}plkL)S+%L~!cWA&k)jvqK7*%# zXBoOMNzcWgxNA~O`%PC(kEKSbU;4s4WWHxfT0XLTXB)6xv%R+OEbA`&%;9!ibo^f4 zS$?|wp>vP(X@#d^uHuR7Or^bYpz>MOj;f2*d#gWL?p=PzJ-G00T4eu+n?2$D+FZCQ zy^o8a4yVLtv)Zigm7bNJu&Ss^w4uJCUTUb1D&ZO-l(5Hb6IP#zCWZ#oSWF!ldbGf3 zPOr~7C009qUgzs?oj&PoOgWJGF>^qPEikWcYAl`-tDN3-tcCN9ryvy**z{is_&ALy zCluz?+??c_l9<@Ht*_DVZ|vK4y~MmRjT--!zU^Ob@7vT92Oz!B3+~{ydUko4eEZICYZfwu)RdkV-wzjJ}e6?e>W)L?TY4{_)~yF#PJ>(eH^I-LN_{F;^%JXF9$l z(m%vmW@dJEdSmB@n?#;6(eaJqRAF-Y=g&WZ?T`l$F@G&ll#V4sT_`M3GJ+C01HEU*36t=>x=m z9s84)uXlKd><-Yy@Y1#1i`9*he8w~lcE&EDnM~cP%qXhNII}MICtPYhR08RunWO2&i>4+ z`#%n0JG4O>IqkqnJFo#OjoQhJgnkO!2~7ktp&fu-5{2p1Ob?R_dIP28ArsKIz%K~P zemg=Pc47F$Z2NXJQ}I9@pft?6j~pC>Seal&$0~w{dsPwm3CJ#R(F=PMPY9_^tdp=X z=qcNN0Kxlk@+4RbLeD!54YtrRByL7i2K?EJi#SYSTDfVcd)w1mtt>H;#l!@ZiFZXBkJ437mAGn5xxLtkcl2|Kn)GgiRRTYj#Ni zFyNzZ+@=%Adyd|!fE=$)NyhHbK$?R~Q)AQ3lkT_AO@t0o20=Y2f@$BsP<#WNV8IVP zlk10;OWOCWm0WQYFACwoWbON)Q$@u#hC??dmE7AZFxIq<&Bz z#hx{-vfDg{p&=IEBEGhd0TJx?FXUt9MaE!G+Rh?Uj94elZuf9~Px@~Rusjn~C4 zvfaYo4Tcmvr|B%M{@!gp=j;fDkUfQLVN?@E^Su~jKdMQiT;?o?+LCB1j|uePmS(MW z=ShUut@rdoE4)*EVKbWOg;ykP1P5|~WGtWQ8O&N2T^Fnk-H9I?KI#knjn5!(S|S8l zknlr}OQawtB>EsHCGe`FDG3vDS^}>`nvqCA7A1Ni&sO*gz)PFnkm!e;m54yjNu(j? zB@D;~i6CT2VgPbcA_sX+A^>?_A_<9?zKv82izNx3=CVWv@=b{z$Q21@XjLM++2?Ll zxP>}&Tc+Re2e7R~l`-WbWn1l1zte`bEk}zZukSdUoh!~euA4QTHJ98jchUW+d$0E8 z+Pj`f&%55Jcf9V;`nT(MeAB+$4X%c-8rvGLG-aB;`CpIs0kh5fo*UZcXN@CA@o!QW aU$fTZBb&{MmxOOwl*Fo76ib48A^HbJhY;BS diff --git a/assets/icons/check_circle_checked.svg b/assets/icons/check_circle_checked.svg new file mode 100644 index 0000000000..df4b5694a0 --- /dev/null +++ b/assets/icons/check_circle_checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/check_circle_unchecked.svg b/assets/icons/check_circle_unchecked.svg new file mode 100644 index 0000000000..f60d58ca9f --- /dev/null +++ b/assets/icons/check_circle_unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 360cbd6d4e..8f31630de2 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -48,113 +48,119 @@ abstract final class ZulipIcons { /// The Zulip custom icon "check". static const IconData check = IconData(0xf108, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_circle_checked". + static const IconData check_circle_checked = IconData(0xf109, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "check_circle_unchecked". + static const IconData check_circle_unchecked = IconData(0xf10a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_remove". - static const IconData check_remove = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData check_remove = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf12e, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From a0ae459139bb132caa9fbecfcad0800344c71891 Mon Sep 17 00:00:00 2001 From: chimnayajith Date: Thu, 24 Apr 2025 22:56:39 +0530 Subject: [PATCH 140/290] new-dm: Add UI for starting new DM conversations Add a modal bottom sheet UI for starting direct messages: - Search and select users from global list - Support single and group DMs - Navigate to message list after selection Design reference: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4903-31879&p=f&t=pQP4QcxpccllCF7g-0 Fixes: #127 Co-authored-by: Chris Bobbe --- assets/l10n/app_en.arb | 12 +- lib/generated/l10n/zulip_localizations.dart | 14 +- .../l10n/zulip_localizations_ar.dart | 5 +- .../l10n/zulip_localizations_de.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 +- .../l10n/zulip_localizations_uk.dart | 5 +- .../l10n/zulip_localizations_zh.dart | 5 +- lib/model/autocomplete.dart | 1 - lib/widgets/new_dm_sheet.dart | 414 ++++++++++++++++++ lib/widgets/recent_dm_conversations.dart | 101 ++++- lib/widgets/theme.dart | 49 +++ test/widgets/new_dm_sheet_test.dart | 314 +++++++++++++ .../widgets/recent_dm_conversations_test.dart | 27 ++ 18 files changed, 905 insertions(+), 77 deletions(-) create mode 100644 lib/widgets/new_dm_sheet.dart create mode 100644 test/widgets/new_dm_sheet_test.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 487b519bb5..fb62815eef 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -409,13 +409,9 @@ "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." }, - "newDmSheetBackButtonLabel": "Back", - "@newDmSheetBackButtonLabel": { - "description": "Label for the back button in the new DM sheet, allowing the user to return to the previous screen." - }, - "newDmSheetNextButtonLabel": "Next", - "@newDmSheetNextButtonLabel": { - "description": "Label for the front button in the new DM sheet, if applicable, for navigation or action." + "newDmSheetComposeButtonLabel": "Compose", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." }, "newDmSheetScreenTitle": "New DM", "@newDmSheetScreenTitle": { @@ -431,7 +427,7 @@ }, "newDmSheetSearchHintSomeSelected": "Add another user…", "@newDmSheetSearchHintSomeSelected": { - "description": "Hint text for the search bar when at least one user is selected" + "description": "Hint text for the search bar when at least one user is selected." }, "newDmSheetNoUsersFound": "No users found", "@newDmSheetNoUsersFound": { diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index f54e67f029..c990e54155 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -691,17 +691,11 @@ abstract class ZulipLocalizations { /// **'Type a message'** String get composeBoxGenericContentHint; - /// Label for the back button in the new DM sheet, allowing the user to return to the previous screen. + /// Label for the compose button in the new DM sheet that starts composing a message to the selected users. /// /// In en, this message translates to: - /// **'Back'** - String get newDmSheetBackButtonLabel; - - /// Label for the front button in the new DM sheet, if applicable, for navigation or action. - /// - /// In en, this message translates to: - /// **'Next'** - String get newDmSheetNextButtonLabel; + /// **'Compose'** + String get newDmSheetComposeButtonLabel; /// Title displayed at the top of the new DM screen. /// @@ -721,7 +715,7 @@ abstract class ZulipLocalizations { /// **'Add one or more users'** String get newDmSheetSearchHintEmpty; - /// Hint text for the search bar when at least one user is selected + /// Hint text for the search bar when at least one user is selected. /// /// In en, this message translates to: /// **'Add another user…'** diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 4367346f5d..50f55ee551 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 1b305f0d00..06a9af57ea 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index c5ac2018d0..de74e5d0e7 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 745e4ee726..3d96c28e2f 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d0f1d0cb4b..029c385574 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 423aef00fd..1be9bd80e8 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -351,10 +351,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Wpisz wiadomość'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 2e3a876f0d..7fa305729f 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -352,10 +352,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Ввести сообщение'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 0e477ccd26..132a5355e9 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index ca78daad56..d76cbec6d9 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -353,10 +353,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Ввести повідомлення'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 85f054ae34..4e74b4c95c 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 034199521d..cd3fa7d7d2 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -556,7 +556,6 @@ class MentionAutocompleteView extends AutocompleteView( + context: pageContext, + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (BuildContext context) => Padding( + // By default, when software keyboard is opened, the ListView + // expands behind the software keyboard — resulting in some + // list entries being covered by the keyboard. Add explicit + // bottom padding the size of the keyboard, which fixes this. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: PerAccountStoreWidget( + accountId: store.accountId, + child: NewDmPicker()))); +} + +@visibleForTesting +class NewDmPicker extends StatefulWidget { + const NewDmPicker({super.key}); + + @override + State createState() => _NewDmPickerState(); +} + +class _NewDmPickerState extends State with PerAccountStoreAwareStateMixin { + late TextEditingController searchController; + late ScrollController resultsScrollController; + Set selectedUserIds = {}; + List filteredUsers = []; + List sortedUsers = []; + + @override + void initState() { + super.initState(); + searchController = TextEditingController()..addListener(_handleSearchUpdate); + resultsScrollController = ScrollController(); + } + + @override + void onNewStore() { + final store = PerAccountStoreWidget.of(context); + _initSortedUsers(store); + } + + @override + void dispose() { + searchController.dispose(); + resultsScrollController.dispose(); + super.dispose(); + } + + void _initSortedUsers(PerAccountStore store) { + sortedUsers = List.from(store.allUsers) + ..sort((a, b) => MentionAutocompleteView.compareByDms(a, b, store: store)); + _updateFilteredUsers(store); + } + + void _handleSearchUpdate() { + final store = PerAccountStoreWidget.of(context); + _updateFilteredUsers(store); + } + + // Function to sort users based on recency of DM's + // TODO: switch to using an `AutocompleteView` for users + void _updateFilteredUsers(PerAccountStore store) { + final excludeSelfUser = selectedUserIds.isNotEmpty + && !selectedUserIds.contains(store.selfUserId); + final searchTextLower = searchController.text.toLowerCase(); + + final result = []; + for (final user in sortedUsers) { + if (excludeSelfUser && user.userId == store.selfUserId) continue; + if (user.fullName.toLowerCase().contains(searchTextLower)) { + result.add(user); + } + } + + setState(() { + filteredUsers = result; + }); + + if (resultsScrollController.hasClients) { + // Jump to the first results for the new query. + resultsScrollController.jumpTo(0); + } + } + + void _selectUser(int userId) { + assert(!selectedUserIds.contains(userId)); + final store = PerAccountStoreWidget.of(context); + selectedUserIds.add(userId); + if (userId != store.selfUserId) { + selectedUserIds.remove(store.selfUserId); + } + _updateFilteredUsers(store); + } + + void _unselectUser(int userId) { + assert(selectedUserIds.contains(userId)); + final store = PerAccountStoreWidget.of(context); + selectedUserIds.remove(userId); + _updateFilteredUsers(store); + } + + void _handleUserTap(int userId) { + selectedUserIds.contains(userId) + ? _unselectUser(userId) + : _selectUser(userId); + searchController.clear(); + } + + @override + Widget build(BuildContext context) { + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _NewDmHeader(selectedUserIds: selectedUserIds), + _NewDmSearchBar( + controller: searchController, + selectedUserIds: selectedUserIds), + Expanded( + child: _NewDmUserList( + filteredUsers: filteredUsers, + selectedUserIds: selectedUserIds, + scrollController: resultsScrollController, + onUserTapped: (userId) => _handleUserTap(userId))), + ]); + } +} + +class _NewDmHeader extends StatelessWidget { + const _NewDmHeader({required this.selectedUserIds}); + + final Set selectedUserIds; + + Widget _buildCancelButton(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return GestureDetector( + onTap: Navigator.of(context).pop, + child: Text(zulipLocalizations.dialogCancel, style: TextStyle( + color: designVariables.icon, + fontSize: 20, + height: 30 / 20))); + } + + Widget _buildComposeButton(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final color = selectedUserIds.isEmpty + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; + + return GestureDetector( + onTap: selectedUserIds.isEmpty ? null : () { + final store = PerAccountStoreWidget.of(context); + final narrow = DmNarrow.withUsers( + selectedUserIds.toList(), + selfUserId: store.selfUserId); + Navigator.pushReplacement(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + child: Text(zulipLocalizations.newDmSheetComposeButtonLabel, + style: TextStyle( + color: color, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600)))); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 10, 8, 6), + child: Row(children: [ + _buildCancelButton(context), + SizedBox(width: 8), + Expanded(child: Text(zulipLocalizations.newDmSheetScreenTitle, + style: TextStyle( + color: designVariables.title, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600)), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center)), + SizedBox(width: 8), + _buildComposeButton(context), + ])); + } +} + +class _NewDmSearchBar extends StatelessWidget { + const _NewDmSearchBar({ + required this.controller, + required this.selectedUserIds, + }); + + final TextEditingController controller; + final Set selectedUserIds; + + // void _removeUser + + Widget _buildSearchField(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final hintText = selectedUserIds.isEmpty + ? zulipLocalizations.newDmSheetSearchHintEmpty + : zulipLocalizations.newDmSheetSearchHintSomeSelected; + + return TextField( + controller: controller, + autofocus: true, + cursorColor: designVariables.foreground, + style: TextStyle( + color: designVariables.textMessage, + fontSize: 17, + height: 22 / 17), + scrollPadding: EdgeInsets.zero, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintText: hintText, + hintStyle: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 22 / 17))); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return Container( + constraints: const BoxConstraints(maxHeight: 124), + decoration: BoxDecoration(color: designVariables.bgSearchInput), + child: SingleChildScrollView( + reverse: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final userId in selectedUserIds) + _SelectedUserChip(userId: userId), + // The IntrinsicWidth lets the text field participate in the Wrap + // when its content fits on the same line with a user chip, + // by preventing it from expanding to fill the available width. See: + // https://github.com/zulip/zulip-flutter/pull/1322#discussion_r2094112488 + IntrinsicWidth(child: _buildSearchField(context)), + ])))); + } +} + +class _SelectedUserChip extends StatelessWidget { + const _SelectedUserChip({required this.userId}); + + final int userId; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final clampedTextScaler = MediaQuery.textScalerOf(context) + .clamp(maxScaleFactor: 1.5); + + return DecoratedBox( + decoration: BoxDecoration( + color: designVariables.bgMenuButtonSelected, + borderRadius: BorderRadius.circular(3)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Avatar(userId: userId, size: clampedTextScaler.scale(22), borderRadius: 3), + Flexible( + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(5, 3, 4, 3), + child: Text(store.userDisplayName(userId), + textScaler: clampedTextScaler, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 16 / 16, + color: designVariables.labelMenuButton)))), + ])); + } +} + +class _NewDmUserList extends StatelessWidget { + const _NewDmUserList({ + required this.filteredUsers, + required this.selectedUserIds, + required this.scrollController, + required this.onUserTapped, + }); + + final List filteredUsers; + final Set selectedUserIds; + final ScrollController scrollController; + final void Function(int userId) onUserTapped; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + if (filteredUsers.isEmpty) { + // TODO(design): Missing in Figma. + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + textAlign: TextAlign.center, + zulipLocalizations.newDmSheetNoUsersFound, + style: TextStyle( + color: designVariables.labelMenuButton, + fontSize: 16)))); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: CustomScrollView(controller: scrollController, slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: 8), + sliver: SliverSafeArea( + minimum: EdgeInsets.only(bottom: 8), + sliver: SliverList.builder( + itemCount: filteredUsers.length, + itemBuilder: (context, index) { + final user = filteredUsers[index]; + final isSelected = selectedUserIds.contains(user.userId); + + return _NewDmUserListItem( + userId: user.userId, + isSelected: isSelected, + onTapped: onUserTapped, + ); + }))), + ])); + } +} + +class _NewDmUserListItem extends StatelessWidget { + const _NewDmUserListItem({ + required this.userId, + required this.isSelected, + required this.onTapped, + }); + + final int userId; + final bool isSelected; + final void Function(int userId) onTapped; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + return Material( + clipBehavior: Clip.antiAlias, + borderRadius: BorderRadius.circular(10), + color: isSelected + ? designVariables.bgMenuButtonSelected + : Colors.transparent, + child: InkWell( + highlightColor: designVariables.bgMenuButtonSelected, + splashFactory: NoSplash.splashFactory, + onTap: () => onTapped(userId), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(0, 6, 12, 6), + child: Row(children: [ + SizedBox(width: 8), + isSelected + ? Icon(size: 24, + color: designVariables.radioFillSelected, + ZulipIcons.check_circle_checked) + : Icon(size: 24, + color: designVariables.radioBorder, + ZulipIcons.check_circle_unchecked), + SizedBox(width: 10), + Avatar(userId: userId, size: 32, borderRadius: 3), + SizedBox(width: 8), + Expanded( + child: Text(store.userDisplayName(userId), + style: TextStyle( + fontSize: 17, + height: 19 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)))), + ])))); + } +} diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 1fe9118635..d392998268 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -8,7 +8,9 @@ import 'content.dart'; import 'home.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'new_dm_sheet.dart'; import 'store.dart'; +import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; @@ -53,24 +55,30 @@ class _RecentDmConversationsPageBodyState extends State createState() => _NewDmButtonState(); +} + +class _NewDmButtonState extends State<_NewDmButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final fabBgColor = _pressed + ? designVariables.fabBgPressed + : designVariables.fabBg; + final fabLabelColor = _pressed + ? designVariables.fabLabelPressed + : designVariables.fabLabel; + + return GestureDetector( + onTap: () => showNewDmSheet(context), + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 20, 12), + decoration: BoxDecoration( + color: fabBgColor, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow( + color: designVariables.fabShadow, + blurRadius: _pressed ? 12 : 16, + offset: _pressed + ? const Offset(0, 2) + : const Offset(0, 4)), + ]), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(ZulipIcons.plus, size: 24, color: fabLabelColor), + const SizedBox(width: 8), + Text( + zulipLocalizations.newDmFabButtonLabel, + style: TextStyle( + fontSize: 20, + height: 24 / 20, + color: fabLabelColor, + ).merge(weightVariableTextStyle(context, wght: 500))), + ]))); + } +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 1e5a6fe6ae..72a592f004 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -158,6 +158,11 @@ class DesignVariables extends ThemeExtension { contextMenuItemMeta: const Color(0xff626573), contextMenuItemText: const Color(0xff381da7), editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), + fabBg: const Color(0xff6e69f3), + fabBgPressed: const Color(0xff6159e1), + fabLabel: const Color(0xfff1f3fe), + fabLabelPressed: const Color(0xffeceefc), + fabShadow: const Color(0xff2b0e8a).withValues(alpha: 0.4), foreground: const Color(0xff000000), icon: const Color(0xff6159e1), iconSelected: const Color(0xff222222), @@ -166,6 +171,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xff222222), labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), mainBackground: const Color(0xfff0f0f0), + radioBorder: Color(0xffbbbdc8), + radioFillSelected: Color(0xff4370f0), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), bgSearchInput: const Color(0xffe3e3e3), @@ -220,6 +227,11 @@ class DesignVariables extends ThemeExtension { contextMenuItemMeta: const Color(0xff9194a3), contextMenuItemText: const Color(0xff9398fd), editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), + fabBg: const Color(0xff4f42c9), + fabBgPressed: const Color(0xff4331b8), + fabLabel: const Color(0xffeceefc), + fabLabelPressed: const Color(0xffeceefc), + fabShadow: const Color(0xff18171c), foreground: const Color(0xffffffff), icon: const Color(0xff7977fe), iconSelected: Colors.white.withValues(alpha: 0.8), @@ -228,6 +240,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), mainBackground: const Color(0xff1d1d1d), + radioBorder: Color(0xff626573), + radioFillSelected: Color(0xff4e7cfa), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff).withValues(alpha: 0.9), bgSearchInput: const Color(0xff313131), @@ -291,6 +305,11 @@ class DesignVariables extends ThemeExtension { required this.contextMenuItemText, required this.editorButtonPressedBg, required this.foreground, + required this.fabBg, + required this.fabBgPressed, + required this.fabLabel, + required this.fabLabelPressed, + required this.fabShadow, required this.icon, required this.iconSelected, required this.labelCounterUnread, @@ -298,6 +317,8 @@ class DesignVariables extends ThemeExtension { required this.labelMenuButton, required this.labelSearchPrompt, required this.mainBackground, + required this.radioBorder, + required this.radioFillSelected, required this.textInput, required this.title, required this.bgSearchInput, @@ -361,6 +382,11 @@ class DesignVariables extends ThemeExtension { final Color contextMenuItemMeta; final Color contextMenuItemText; final Color editorButtonPressedBg; + final Color fabBg; + final Color fabBgPressed; + final Color fabLabel; + final Color fabLabelPressed; + final Color fabShadow; final Color foreground; final Color icon; final Color iconSelected; @@ -369,6 +395,8 @@ class DesignVariables extends ThemeExtension { final Color labelMenuButton; final Color labelSearchPrompt; final Color mainBackground; + final Color radioBorder; + final Color radioFillSelected; final Color textInput; final Color title; final Color bgSearchInput; @@ -427,6 +455,11 @@ class DesignVariables extends ThemeExtension { Color? contextMenuItemMeta, Color? contextMenuItemText, Color? editorButtonPressedBg, + Color? fabBg, + Color? fabBgPressed, + Color? fabLabel, + Color? fabLabelPressed, + Color? fabShadow, Color? foreground, Color? icon, Color? iconSelected, @@ -435,6 +468,8 @@ class DesignVariables extends ThemeExtension { Color? labelMenuButton, Color? labelSearchPrompt, Color? mainBackground, + Color? radioBorder, + Color? radioFillSelected, Color? textInput, Color? title, Color? bgSearchInput, @@ -489,6 +524,11 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: contextMenuItemText ?? this.contextMenuItemText, editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, foreground: foreground ?? this.foreground, + fabBg: fabBg ?? this.fabBg, + fabBgPressed: fabBgPressed ?? this.fabBgPressed, + fabLabel: fabLabel ?? this.fabLabel, + fabLabelPressed: fabLabelPressed ?? this.fabLabelPressed, + fabShadow: fabShadow ?? this.fabShadow, icon: icon ?? this.icon, iconSelected: iconSelected ?? this.iconSelected, labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, @@ -496,6 +536,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: labelMenuButton ?? this.labelMenuButton, labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, mainBackground: mainBackground ?? this.mainBackground, + radioBorder: radioBorder ?? this.radioBorder, + radioFillSelected: radioFillSelected ?? this.radioFillSelected, textInput: textInput ?? this.textInput, title: title ?? this.title, bgSearchInput: bgSearchInput ?? this.bgSearchInput, @@ -557,6 +599,11 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemText, t)!, editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, + fabBg: Color.lerp(fabBg, other.fabBg, t)!, + fabBgPressed: Color.lerp(fabBgPressed, other.fabBgPressed, t)!, + fabLabel: Color.lerp(fabLabel, other.fabLabel, t)!, + fabLabelPressed: Color.lerp(fabLabelPressed, other.fabLabelPressed, t)!, + fabShadow: Color.lerp(fabShadow, other.fabShadow, t)!, icon: Color.lerp(icon, other.icon, t)!, iconSelected: Color.lerp(iconSelected, other.iconSelected, t)!, labelCounterUnread: Color.lerp(labelCounterUnread, other.labelCounterUnread, t)!, @@ -564,6 +611,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + radioBorder: Color.lerp(radioBorder, other.radioBorder, t)!, + radioFillSelected: Color.lerp(radioFillSelected, other.radioFillSelected, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart new file mode 100644 index 0000000000..cf91b47a55 --- /dev/null +++ b/test/widgets/new_dm_sheet_test.dart @@ -0,0 +1,314 @@ +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/api/model/model.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../test_navigation.dart'; +import 'test_app.dart'; + +Future setupSheet(WidgetTester tester, { + required List users, +}) async { + addTearDown(testBinding.reset); + + Route? lastPushedRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, _) => lastPushedRoute = route; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers(users); + + await tester.pumpWidget(TestZulipApp( + navigatorObservers: [testNavObserver], + accountId: eg.selfAccount.id, + child: const HomePage())); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(ZulipIcons.user)); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(GestureDetector, 'New DM')); + await tester.pump(); + check(lastPushedRoute).isNotNull().isA>(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); +} + +void main() { + TestZulipBinding.ensureInitialized(); + + final findComposeButton = find.widgetWithText(GestureDetector, 'Compose'); + void checkComposeButtonEnabled(WidgetTester tester, bool expected) { + final button = tester.widget(findComposeButton); + if (expected) { + check(button.onTap).isNotNull(); + } else { + check(button.onTap).isNull(); + } + } + + Finder findUserTile(User user) => + find.widgetWithText(InkWell, user.fullName).first; + + Finder findUserChip(User user) => + find.byWidgetPredicate((widget) => + widget is Avatar + && widget.userId == user.userId + && widget.size == 22); + + testWidgets('shows header with correct buttons', (tester) async { + await setupSheet(tester, users: []); + + check(find.descendant( + of: find.byType(NewDmPicker), + matching: find.text('New DM'))).findsOne(); + check(find.text('Cancel')).findsOne(); + check(findComposeButton).findsOne(); + + checkComposeButtonEnabled(tester, false); + }); + + testWidgets('search field has focus when sheet opens', (tester) async { + await setupSheet(tester, users: []); + + void checkHasFocus() { + // Some element is focused… + final focusedElement = tester.binding.focusManager.primaryFocus?.context; + check(focusedElement).isNotNull(); + + // …it's a TextField. Specifically, the search input. + final focusedTextFieldWidget = focusedElement! + .findAncestorWidgetOfExactType(); + check(focusedTextFieldWidget).isNotNull() + .decoration.isNotNull() + .hintText.equals('Add one or more users'); + } + + checkHasFocus(); // It's focused initially. + await tester.pump(Duration(seconds: 1)); + checkHasFocus(); // Something else doesn't come along and steal the focus. + }); + + group('user filtering', () { + final testUsers = [ + eg.user(fullName: 'Alice Anderson'), + eg.user(fullName: 'Bob Brown'), + eg.user(fullName: 'Charlie Carter'), + ]; + + testWidgets('shows all users initially', (tester) async { + await setupSheet(tester, users: testUsers); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Bob Brown')).findsOne(); + check(find.text('Charlie Carter')).findsOne(); + }); + + testWidgets('shows filtered users based on search', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + }); + + // TODO test sorting by recent-DMs + // TODO test that scroll position resets on query change + + testWidgets('search is case-insensitive', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'alice'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + + await tester.enterText(find.byType(TextField), 'ALICE'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + }); + + testWidgets('partial name and last name search handling', (tester) async { + await setupSheet(tester, users: testUsers); + + await tester.enterText(find.byType(TextField), 'Ali'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Bob Brown')).findsNothing(); + check(find.text('Charlie Carter')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'Anderson'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'son'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + }); + + testWidgets('shows empty state when no users match', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Zebra'); + await tester.pump(); + check(find.text('No users found')).findsOne(); + check(find.text('Alice Anderson')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + check(find.text('Charlie Carter')).findsNothing(); + }); + + testWidgets('search text clears when user is selected', (tester) async { + final user = eg.user(fullName: 'Test User'); + await setupSheet(tester, users: [user]); + + await tester.enterText(find.byType(TextField), 'Test'); + await tester.pump(); + final textField = tester.widget(find.byType(TextField)); + check(textField.controller!.text).equals('Test'); + + await tester.tap(findUserTile(user)); + await tester.pump(); + check(textField.controller!.text).isEmpty(); + }); + }); + + group('user selection', () { + void checkUserSelected(WidgetTester tester, User user, bool expected) { + final icon = tester.widget(find.descendant( + of: findUserTile(user), + matching: find.byType(Icon))); + + if (expected) { + check(findUserChip(user)).findsOne(); + check(icon).icon.equals(ZulipIcons.check_circle_checked); + } else { + check(findUserChip(user)).findsNothing(); + check(icon).icon.equals(ZulipIcons.check_circle_unchecked); + } + } + + testWidgets('selecting and deselecting a user', (tester) async { + final user = eg.user(fullName: 'Test User'); + await setupSheet(tester, users: [eg.selfUser, user]); + + checkUserSelected(tester, user, false); + checkUserSelected(tester, eg.selfUser, false); + checkComposeButtonEnabled(tester, false); + + await tester.tap(findUserTile(user)); + await tester.pump(); + checkUserSelected(tester, user, true); + checkComposeButtonEnabled(tester, true); + + await tester.tap(findUserTile(user)); + await tester.pump(); + checkUserSelected(tester, user, false); + checkComposeButtonEnabled(tester, false); + }); + + testWidgets('other user selection deselects self user', (tester) async { + final otherUser = eg.user(fullName: 'Other User'); + await setupSheet(tester, users: [eg.selfUser, otherUser]); + + await tester.tap(findUserTile(eg.selfUser)); + await tester.pump(); + checkUserSelected(tester, eg.selfUser, true); + check(find.text(eg.selfUser.fullName)).findsExactly(2); + + await tester.tap(findUserTile(otherUser)); + await tester.pump(); + checkUserSelected(tester, otherUser, true); + check(find.text(eg.selfUser.fullName)).findsNothing(); + }); + + testWidgets('other user selection hides self user', (tester) async { + final otherUser = eg.user(fullName: 'Other User'); + await setupSheet(tester, users: [eg.selfUser, otherUser]); + + check(find.text(eg.selfUser.fullName)).findsOne(); + + await tester.tap(findUserTile(otherUser)); + await tester.pump(); + check(find.text(eg.selfUser.fullName)).findsNothing(); + }); + + testWidgets('can select multiple users', (tester) async { + final user1 = eg.user(fullName: 'Test User 1'); + final user2 = eg.user(fullName: 'Test User 2'); + await setupSheet(tester, users: [user1, user2]); + + await tester.tap(findUserTile(user1)); + await tester.pump(); + await tester.tap(findUserTile(user2)); + await tester.pump(); + checkUserSelected(tester, user1, true); + checkUserSelected(tester, user2, true); + }); + }); + + group('navigation to DM Narrow', () { + Future runAndCheck(WidgetTester tester, { + required List users, + required String expectedAppBarTitle, + }) async { + await setupSheet(tester, users: users); + + final context = tester.element(find.byType(NewDmPicker)); + final store = PerAccountStoreWidget.of(context); + final connection = store.connection as FakeApiConnection; + + connection.prepare( + json: eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + for (final user in users) { + await tester.tap(findUserTile(user)); + await tester.pump(); + } + await tester.tap(findComposeButton); + await tester.pumpAndSettle(); + check(find.widgetWithText(ZulipAppBar, expectedAppBarTitle)).findsOne(); + + check(find.byType(ComposeBox)).findsOne(); + } + + testWidgets('navigates to self DM', (tester) async { + await runAndCheck( + tester, + users: [eg.selfUser], + expectedAppBarTitle: 'DMs with yourself'); + }); + + testWidgets('navigates to 1:1 DM', (tester) async { + final user = eg.user(fullName: 'Test User'); + await runAndCheck( + tester, + users: [user], + expectedAppBarTitle: 'DMs with Test User'); + }); + + testWidgets('navigates to group DM', (tester) async { + final users = [ + eg.user(fullName: 'User 1'), + eg.user(fullName: 'User 2'), + eg.user(fullName: 'User 3'), + ]; + await runAndCheck( + tester, + users: users, + expectedAppBarTitle: 'DMs with User 1, User 2, User 3'); + }); + }); +} diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 7568c52043..6bd01b40c8 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -10,6 +10,7 @@ import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/recent_dm_conversations.dart'; @@ -113,6 +114,32 @@ void main() { await tester.pumpAndSettle(); check(tester.any(oldestConversationFinder)).isTrue(); // onscreen }); + + testWidgets('opens new DM sheet on New DM button tap', (tester) async { + Route? lastPushedRoute; + Route? lastPoppedRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = ((route, _) => lastPushedRoute = route) + ..onPopped = ((route, _) => lastPoppedRoute = route); + + await setupPage(tester, navigatorObserver: testNavObserver, + users: [], dmMessages: []); + + await tester.tap(find.widgetWithText(GestureDetector, 'New DM')); + await tester.pump(); + check(lastPushedRoute).isA>(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + check(find.byType(NewDmPicker)).findsOne(); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + check(lastPoppedRoute).isA>(); + await tester.pump( + (lastPoppedRoute as TransitionRoute).reverseTransitionDuration + // TODO not sure why a 1ms fudge is needed; investigate. + + Duration(milliseconds: 1)); + check(find.byType(NewDmPicker)).findsNothing(); + }); }); group('RecentDmConversationsItem', () { From 0058cd7328af10c160d7bf758ca5c4edd3d462d8 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 5 Jun 2025 21:15:31 -0700 Subject: [PATCH 141/290] new-dm: Support unselecting a user by tapping chip in input This isn't specifically mentioned in the Figma, but I think it's really helpful. Without this, the only way to deselect a user is to find them again in the list of results. That could be annoying because the user will often go offscreen in the results list as soon as you tap it, because we reset the query and scroll state when you tap it. Discussion about adding an "x" to the chip, to make this behavior more visible: https://chat.zulip.org/#narrow/channel/516-mobile-dev-help/topic/Follow.20up.20on.20comments.20.20on.20new.20Dm.20Sheet/near/2185229 --- lib/widgets/new_dm_sheet.dart | 51 +++++++++++++++++------------ test/widgets/new_dm_sheet_test.dart | 18 ++++++++-- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index 07d30a79fa..f81a66de42 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -133,7 +133,8 @@ class _NewDmPickerState extends State with PerAccountStoreAwareStat _NewDmHeader(selectedUserIds: selectedUserIds), _NewDmSearchBar( controller: searchController, - selectedUserIds: selectedUserIds), + selectedUserIds: selectedUserIds, + unselectUser: _unselectUser), Expanded( child: _NewDmUserList( filteredUsers: filteredUsers, @@ -215,10 +216,12 @@ class _NewDmSearchBar extends StatelessWidget { const _NewDmSearchBar({ required this.controller, required this.selectedUserIds, + required this.unselectUser, }); final TextEditingController controller; final Set selectedUserIds; + final void Function(int) unselectUser; // void _removeUser @@ -266,7 +269,7 @@ class _NewDmSearchBar extends StatelessWidget { crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final userId in selectedUserIds) - _SelectedUserChip(userId: userId), + _SelectedUserChip(userId: userId, unselectUser: unselectUser), // The IntrinsicWidth lets the text field participate in the Wrap // when its content fits on the same line with a user chip, // by preventing it from expanding to fill the available width. See: @@ -277,9 +280,13 @@ class _NewDmSearchBar extends StatelessWidget { } class _SelectedUserChip extends StatelessWidget { - const _SelectedUserChip({required this.userId}); + const _SelectedUserChip({ + required this.userId, + required this.unselectUser, + }); final int userId; + final void Function(int) unselectUser; @override Widget build(BuildContext context) { @@ -288,24 +295,26 @@ class _SelectedUserChip extends StatelessWidget { final clampedTextScaler = MediaQuery.textScalerOf(context) .clamp(maxScaleFactor: 1.5); - return DecoratedBox( - decoration: BoxDecoration( - color: designVariables.bgMenuButtonSelected, - borderRadius: BorderRadius.circular(3)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Avatar(userId: userId, size: clampedTextScaler.scale(22), borderRadius: 3), - Flexible( - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(5, 3, 4, 3), - child: Text(store.userDisplayName(userId), - textScaler: clampedTextScaler, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - height: 16 / 16, - color: designVariables.labelMenuButton)))), - ])); + return GestureDetector( + onTap: () => unselectUser(userId), + child: DecoratedBox( + decoration: BoxDecoration( + color: designVariables.bgMenuButtonSelected, + borderRadius: BorderRadius.circular(3)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Avatar(userId: userId, size: clampedTextScaler.scale(22), borderRadius: 3), + Flexible( + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(5, 3, 4, 3), + child: Text(store.userDisplayName(userId), + textScaler: clampedTextScaler, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 16 / 16, + color: designVariables.labelMenuButton)))), + ]))); } } diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index cf91b47a55..f1f72d272d 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -63,12 +63,15 @@ void main() { Finder findUserTile(User user) => find.widgetWithText(InkWell, user.fullName).first; - Finder findUserChip(User user) => - find.byWidgetPredicate((widget) => + Finder findUserChip(User user) { + final findAvatar = find.byWidgetPredicate((widget) => widget is Avatar && widget.userId == user.userId && widget.size == 22); + return find.ancestor(of: findAvatar, matching: find.byType(GestureDetector)); + } + testWidgets('shows header with correct buttons', (tester) async { await setupSheet(tester, users: []); @@ -201,6 +204,17 @@ void main() { } } + testWidgets('tapping user chip deselects the user', (tester) async { + await setupSheet(tester, users: [eg.selfUser, eg.otherUser, eg.thirdUser]); + + await tester.tap(findUserTile(eg.otherUser)); + await tester.pump(); + checkUserSelected(tester, eg.otherUser, true); + await tester.tap(findUserChip(eg.otherUser)); + await tester.pump(); + checkUserSelected(tester, eg.otherUser, false); + }); + testWidgets('selecting and deselecting a user', (tester) async { final user = eg.user(fullName: 'Test User'); await setupSheet(tester, users: [eg.selfUser, user]); From 5afac689975c9e22ab6c0ec44f7b37381bd026b7 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Thu, 12 Jun 2025 04:48:06 +0200 Subject: [PATCH 142/290] l10n: Update translations from Weblate. --- assets/l10n/app_de.arb | 23 ++++++- assets/l10n/app_pl.arb | 56 +++++++++++++++-- assets/l10n/app_ru.arb | 60 +++++++++++++++++-- assets/l10n/app_uk.arb | 8 --- .../l10n/zulip_localizations_de.dart | 10 ++-- .../l10n/zulip_localizations_pl.dart | 30 +++++----- .../l10n/zulip_localizations_ru.dart | 29 ++++----- 7 files changed, 164 insertions(+), 52 deletions(-) diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb index 0967ef424b..d1def3c893 100644 --- a/assets/l10n/app_de.arb +++ b/assets/l10n/app_de.arb @@ -1 +1,22 @@ -{} +{ + "settingsPageTitle": "Einstellungen", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "aboutPageTitle": "Über Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "aboutPageAppVersion": "App-Version", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "chooseAccountPageTitle": "Konto auswählen", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "switchAccountButton": "Konto wechseln", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + } +} diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 4c6dc378ea..0569169d4c 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -73,7 +73,7 @@ "@actionSheetOptionMarkAsUnread": { "description": "Label for mark as unread button on action sheet." }, - "logOutConfirmationDialogMessage": "Aby użyć tego konta należy wypełnić URL organizacji oraz dane konta.", + "logOutConfirmationDialogMessage": "Aby użyć tego konta należy wskazać URL organizacji oraz dane konta.", "@logOutConfirmationDialogMessage": { "description": "Message for a confirmation dialog for logging out." }, @@ -699,7 +699,7 @@ "example": "http://chat.example.com/" } }, - "tryAnotherAccountButton": "Sprawdź inne konto", + "tryAnotherAccountButton": "Użyj innego konta", "@tryAnotherAccountButton": { "description": "Label for loading screen button prompting user to try another account." }, @@ -867,10 +867,6 @@ "@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." @@ -1076,5 +1072,53 @@ "actionSheetOptionListOfTopics": "Lista wątków", "@actionSheetOptionListOfTopics": { "description": "Label for navigating to a channel's topic-list page." + }, + "newDmSheetScreenTitle": "Nowa DM", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "Nowa DM", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintSomeSelected": "Dodaj kolejnego użytkownika…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected" + }, + "mutedSender": "Wyciszony nadawca", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "revealButtonLabel": "Odsłoń wiadomość od wyciszonego użytkownika", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Wyciszony użytkownik", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "newDmSheetNoUsersFound": "Nie odnaleziono użytkowników", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "actionSheetOptionHideMutedMessage": "Ukryj ponownie wyciszone wiadomości", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "newDmSheetSearchHintEmpty": "Dodaj jednego lub więcej użytkowników", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "messageNotSentLabel": "NIE WYSŁANO WIADOMOŚCI", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftForMessageNotSentConfirmationDialogMessage": "Odzyskanie wiadomości, która nie została wysłana, skutkuje wyczyszczeniem zawartości pola dodania wpisu.", + "@discardDraftForMessageNotSentConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." + }, + "errorNotificationOpenAccountNotFound": "Nie odnaleziono konta powiązanego z tym powiadomieniem.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index c5c29419c6..b752df8dab 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -799,10 +799,6 @@ "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." }, - "subscriptionListNoChannels": "Каналы не найдены", - "@subscriptionListNoChannels": { - "description": "Text to display on subscribed-channels page when there are no subscribed channels." - }, "wildcardMentionAll": "все", "@wildcardMentionAll": { "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." @@ -1068,5 +1064,61 @@ "discardDraftForEditConfirmationDialogMessage": "При изменении сообщения текст из поля для редактирования удаляется.", "@discardDraftForEditConfirmationDialogMessage": { "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "actionSheetOptionListOfTopics": "Список тем", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "topicsButtonLabel": "ТЕМЫ", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "newDmSheetSearchHintEmpty": "Добавить пользователей", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "mutedSender": "Отключенный отправитель", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "revealButtonLabel": "Показать сообщение отключенного отправителя", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Отключенный пользователь", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "newDmSheetNoUsersFound": "Никто не найден", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "messageNotSentLabel": "СООБЩЕНИЕ НЕ ОТПРАВЛЕНО", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "actionSheetOptionHideMutedMessage": "Скрыть отключенное сообщение", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "newDmFabButtonLabel": "Новое ЛС", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "discardDraftForMessageNotSentConfirmationDialogMessage": "При восстановлении неотправленного сообщения текст в поле ввода текста будет утрачен.", + "@discardDraftForMessageNotSentConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." + }, + "newDmSheetScreenTitle": "Новое ЛС", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmSheetSearchHintSomeSelected": "Добавить еще…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected" + }, + "errorNotificationOpenAccountNotFound": "Учетная запись, связанная с этим уведомлением, не найдена.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" } } diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb index 686b1345a6..b2f60e2453 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -959,10 +959,6 @@ "@combinedFeedPageTitle": { "description": "Page title for the 'Combined feed' message view." }, - "subscriptionListNoChannels": "Канали не знайдено", - "@subscriptionListNoChannels": { - "description": "Text to display on subscribed-channels page when there are no subscribed channels." - }, "reactedEmojiSelfUser": "Ви", "@reactedEmojiSelfUser": { "description": "Display name for the user themself, to show on an emoji reaction added by the user." @@ -991,10 +987,6 @@ "@experimentalFeatureSettingsWarning": { "description": "Warning text on settings page for experimental, in-development features" }, - "errorNotificationOpenAccountMissing": "Обліковий запис, пов’язаний із цим сповіщенням, більше не існує.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "errorReactionAddingFailedTitle": "Не вдалося додати реакцію", "@errorReactionAddingFailedTitle": { "description": "Error title when adding a message reaction fails" diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 06a9af57ea..43a5becc51 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -9,10 +9,10 @@ class ZulipLocalizationsDe extends ZulipLocalizations { ZulipLocalizationsDe([String locale = 'de']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Über Zulip'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'App-Version'; @override String get aboutPageOpenSourceLicenses => 'Open-source licenses'; @@ -21,13 +21,13 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get aboutPageTapToView => 'Tap to view'; @override - String get chooseAccountPageTitle => 'Choose account'; + String get chooseAccountPageTitle => 'Konto auswählen'; @override - String get settingsPageTitle => 'Settings'; + String get settingsPageTitle => 'Einstellungen'; @override - String get switchAccountButton => 'Switch account'; + String get switchAccountButton => 'Konto wechseln'; @override String tryAnotherAccountMessage(Object url) { diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 1be9bd80e8..0752efa496 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -35,7 +35,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { } @override - String get tryAnotherAccountButton => 'Sprawdź inne konto'; + String get tryAnotherAccountButton => 'Użyj innego konta'; @override String get chooseAccountPageLogOutButton => 'Wyloguj'; @@ -45,7 +45,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get logOutConfirmationDialogMessage => - 'Aby użyć tego konta należy wypełnić URL organizacji oraz dane konta.'; + 'Aby użyć tego konta należy wskazać URL organizacji oraz dane konta.'; @override String get logOutConfirmationDialogConfirmButton => 'Wyloguj'; @@ -119,7 +119,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Odtąd oznacz jako nieprzeczytane'; @override - String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + String get actionSheetOptionHideMutedMessage => + 'Ukryj ponownie wyciszone wiadomości'; @override String get actionSheetOptionShare => 'Udostępnij'; @@ -333,7 +334,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + 'Odzyskanie wiadomości, która nie została wysłana, skutkuje wyczyszczeniem zawartości pola dodania wpisu.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; @@ -354,19 +355,20 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get newDmSheetComposeButtonLabel => 'Compose'; @override - String get newDmSheetScreenTitle => 'New DM'; + String get newDmSheetScreenTitle => 'Nowa DM'; @override - String get newDmFabButtonLabel => 'New DM'; + String get newDmFabButtonLabel => 'Nowa DM'; @override - String get newDmSheetSearchHintEmpty => 'Add one or more users'; + String get newDmSheetSearchHintEmpty => + 'Dodaj jednego lub więcej użytkowników'; @override - String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + String get newDmSheetSearchHintSomeSelected => 'Dodaj kolejnego użytkownika…'; @override - String get newDmSheetNoUsersFound => 'No users found'; + String get newDmSheetNoUsersFound => 'Nie odnaleziono użytkowników'; @override String composeBoxDmContentHint(String user) { @@ -754,7 +756,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get messageIsMovedLabel => 'PRZENIESIONO'; @override - String get messageNotSentLabel => 'MESSAGE NOT SENT'; + String get messageNotSentLabel => 'NIE WYSŁANO WIADOMOŚCI'; @override String pollVoterNames(String voterNames) { @@ -795,7 +797,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorNotificationOpenAccountNotFound => - 'The account associated with this notification could not be found.'; + 'Nie odnaleziono konta powiązanego z tym powiadomieniem.'; @override String get errorReactionAddingFailedTitle => 'Dodanie reakcji bez powodzenia'; @@ -814,13 +816,13 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get noEarlierMessages => 'Brak historii'; @override - String get mutedSender => 'Muted sender'; + String get mutedSender => 'Wyciszony nadawca'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Odsłoń wiadomość od wyciszonego użytkownika'; @override - String get mutedUser => 'Muted user'; + String get mutedUser => 'Wyciszony użytkownik'; @override String get scrollToBottomTooltip => 'Przewiń do dołu'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 7fa305729f..22d2d7a337 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -79,7 +79,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'Отметить канал как прочитанный'; @override - String get actionSheetOptionListOfTopics => 'List of topics'; + String get actionSheetOptionListOfTopics => 'Список тем'; @override String get actionSheetOptionMuteTopic => 'Отключить тему'; @@ -119,7 +119,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'Отметить как непрочитанные начиная отсюда'; @override - String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + String get actionSheetOptionHideMutedMessage => + 'Скрыть отключенное сообщение'; @override String get actionSheetOptionShare => 'Поделиться'; @@ -334,7 +335,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + 'При восстановлении неотправленного сообщения текст в поле ввода текста будет утрачен.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; @@ -355,19 +356,19 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get newDmSheetComposeButtonLabel => 'Compose'; @override - String get newDmSheetScreenTitle => 'New DM'; + String get newDmSheetScreenTitle => 'Новое ЛС'; @override - String get newDmFabButtonLabel => 'New DM'; + String get newDmFabButtonLabel => 'Новое ЛС'; @override - String get newDmSheetSearchHintEmpty => 'Add one or more users'; + String get newDmSheetSearchHintEmpty => 'Добавить пользователей'; @override - String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + String get newDmSheetSearchHintSomeSelected => 'Добавить еще…'; @override - String get newDmSheetNoUsersFound => 'No users found'; + String get newDmSheetNoUsersFound => 'Никто не найден'; @override String composeBoxDmContentHint(String user) { @@ -683,7 +684,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get mainMenuMyProfile => 'Мой профиль'; @override - String get topicsButtonLabel => 'TOPICS'; + String get topicsButtonLabel => 'ТЕМЫ'; @override String get channelFeedButtonTooltip => 'Лента канала'; @@ -758,7 +759,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; @override - String get messageNotSentLabel => 'MESSAGE NOT SENT'; + String get messageNotSentLabel => 'СООБЩЕНИЕ НЕ ОТПРАВЛЕНО'; @override String pollVoterNames(String voterNames) { @@ -799,7 +800,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorNotificationOpenAccountNotFound => - 'The account associated with this notification could not be found.'; + 'Учетная запись, связанная с этим уведомлением, не найдена.'; @override String get errorReactionAddingFailedTitle => 'Не удалось добавить реакцию'; @@ -817,13 +818,13 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get noEarlierMessages => 'Предшествующих сообщений нет'; @override - String get mutedSender => 'Muted sender'; + String get mutedSender => 'Отключенный отправитель'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Показать сообщение отключенного отправителя'; @override - String get mutedUser => 'Muted user'; + String get mutedUser => 'Отключенный пользователь'; @override String get scrollToBottomTooltip => 'Пролистать вниз'; From 796dcdab8618691878211599c75f79442d85f677 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 11 Jun 2025 23:03:03 -0700 Subject: [PATCH 143/290] version: Sync version and changelog from v0.0.31 release --- docs/changelog.md | 34 ++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index b806d82aeb..af0497c639 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,40 @@ ## Unreleased +## 0.0.31 (2025-06-11) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +We're nearing ready to have this new app replace the legacy +Zulip mobile app, next week. + +In addition to all the features in the last beta: +* Conversations open at your first unread message. (#80) +* TeX support now enabled by default, and covers a larger + set of expressions. More to come later. (#46) +* Numerous small improvements to the newest features: + muted users (#296), start a DM thread (#127), + recover failed send (#1441), open mid-history (#82). + + +### Highlights for developers + +* Resolved in main: #1540, #385, #386, #127 + +* Resolved in the experimental branch: + * #82 via PR #1566 + * #80 via PR #1517 + * #1441 via PR #1453 + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #1147 via PR #1379 + * #296 via PR #1561 + + ## 0.0.30 (2025-05-28) This is a preview beta, including some experimental changes diff --git a/pubspec.yaml b/pubspec.yaml index 4a94d439c5..68ad256356 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.30+30 +version: 0.0.31+31 environment: # We use a recent version of Flutter from its main channel, and From 0338752c0f18dc82b716062ae1b6c2b6b9971685 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 11 Jun 2025 23:01:52 -0700 Subject: [PATCH 144/290] changelog: Tweak some wording in v0.0.31 to read a bit smoother This is the wording I ended up actually using in the various announcements of the release. --- docs/changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index af0497c639..3dcb4c84ab 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,8 +11,8 @@ not yet merged to the main branch. ### Highlights for users -We're nearing ready to have this new app replace the legacy -Zulip mobile app, next week. +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. In addition to all the features in the last beta: * Conversations open at your first unread message. (#80) From 211b545f35ab85301e1be68d8f352d69afd69e4b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 2 Jun 2025 17:29:15 -0700 Subject: [PATCH 145/290] msglist: When initial message fetch comes up empty, auto-focus compose box This is part of our plan to streamline the new-DM UI: when you start a new DM conversation with no history, we should auto-focus the content input in the compose box. Fixes: #1543 --- lib/widgets/compose_box.dart | 22 ++++++++ lib/widgets/message_list.dart | 11 ++++ test/widgets/compose_box_checks.dart | 5 ++ test/widgets/compose_box_test.dart | 76 +++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 2f47f05f44..57bf1d0a5c 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1546,6 +1546,15 @@ sealed class ComposeBoxController { final content = ComposeContentController(); final contentFocusNode = FocusNode(); + /// If no input is focused, requests focus on the appropriate input. + /// + /// This encapsulates choosing the topic or content input + /// when both exist (see [StreamComposeBoxController.requestFocusIfUnfocused]). + void requestFocusIfUnfocused() { + if (contentFocusNode.hasFocus) return; + contentFocusNode.requestFocus(); + } + @mustCallSuper void dispose() { content.dispose(); @@ -1609,6 +1618,19 @@ class StreamComposeBoxController extends ComposeBoxController { final ValueNotifier topicInteractionStatus = ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen); + @override void requestFocusIfUnfocused() { + if (topicFocusNode.hasFocus || contentFocusNode.hasFocus) return; + switch (topicInteractionStatus.value) { + case ComposeTopicInteractionStatus.notEditingNotChosen: + topicFocusNode.requestFocus(); + case ComposeTopicInteractionStatus.isEditing: + // (should be impossible given early-return on topicFocusNode.hasFocus) + break; + case ComposeTopicInteractionStatus.hasChosen: + contentFocusNode.requestFocus(); + } + } + @override void dispose() { topic.dispose(); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 542d0a52b2..aa9684b7f2 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -526,6 +526,8 @@ class _MessageListState extends State with PerAccountStoreAwareStat model.fetchInitial(); } + bool _prevFetched = false; + void _modelChanged() { if (model.narrow != widget.narrow) { // Either: @@ -539,6 +541,15 @@ class _MessageListState extends State with PerAccountStoreAwareStat // The actual state lives in the [MessageListView] model. // This method was called because that just changed. }); + + if (!_prevFetched && model.fetched && model.messages.isEmpty) { + // If the fetch came up empty, there's nothing to read, + // so opening the keyboard won't be bothersome and could be helpful. + // It's definitely helpful if we got here from the new-DM page. + MessageListPage.ancestorOf(context) + .composeBoxState?.controller.requestFocusIfUnfocused(); + } + _prevFetched = model.fetched; } void _handleScrollMetrics(ScrollMetrics scrollMetrics) { diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart index b93ff7f1bf..349e8cd971 100644 --- a/test/widgets/compose_box_checks.dart +++ b/test/widgets/compose_box_checks.dart @@ -11,6 +11,11 @@ extension ComposeBoxControllerChecks on Subject { Subject get contentFocusNode => has((c) => c.contentFocusNode, 'contentFocusNode'); } +extension StreamComposeBoxControllerChecks on Subject { + Subject get topic => has((c) => c.topic, 'topic'); + Subject get topicFocusNode => has((c) => c.topicFocusNode, 'topicFocusNode'); +} + extension EditMessageComposeBoxControllerChecks on Subject { Subject get messageId => has((c) => c.messageId, 'messageId'); Subject get originalRawContent => has((c) => c.originalRawContent, 'originalRawContent'); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index b4de306aa3..c25b00793e 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_checks/flutter_checks.dart'; @@ -56,14 +57,23 @@ void main() { User? selfUser, List otherUsers = const [], List streams = const [], + List? messages, bool? mandatoryTopics, int? zulipFeatureLevel, }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { - assert(streams.any((stream) => stream.streamId == streamId), + final channel = streams.firstWhereOrNull((s) => s.streamId == streamId); + assert(channel != null, 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); + if (narrow is ChannelNarrow) { + // By default, bypass the complexity where the topic input is autofocused + // on an empty fetch, by making the fetch not empty. (In particular that + // complexity includes a getStreamTopics fetch for topic autocomplete.) + messages ??= [eg.streamMessage(stream: channel)]; + } } addTearDown(testBinding.reset); + messages ??= []; selfUser ??= eg.selfUser; zulipFeatureLevel ??= eg.futureZulipFeatureLevel; final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel); @@ -81,7 +91,11 @@ void main() { connection = store.connection as FakeApiConnection; connection.prepare(json: - eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + if (narrow is ChannelNarrow && messages.isEmpty) { + // The topic input will autofocus, triggering a getStreamTopics request. + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + } await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, child: MessageListPage(initNarrow: narrow))); await tester.pumpAndSettle(); @@ -134,6 +148,64 @@ void main() { await tester.pump(Duration.zero); } + group('auto focus', () { + testWidgets('ChannelNarrow, non-empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [eg.streamMessage(stream: channel)]); + check(controller).isA() + ..topicFocusNode.hasFocus.isFalse() + ..contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('ChannelNarrow, empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: []); + check(controller).isA() + .topicFocusNode.hasFocus.isTrue(); + }); + + testWidgets('TopicNarrow, non-empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic')]); + check(controller).isNotNull().contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('TopicNarrow, empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: []); + check(controller).isNotNull().contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('DmNarrow, non-empty fetch', (tester) async { + final user = eg.user(); + await prepareComposeBox(tester, + selfUser: eg.selfUser, + narrow: DmNarrow.withUser(user.userId, selfUserId: eg.selfUser.userId), + messages: [eg.dmMessage(from: user, to: [eg.selfUser])]); + check(controller).isNotNull().contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('DmNarrow, empty fetch', (tester) async { + await prepareComposeBox(tester, + selfUser: eg.selfUser, + narrow: DmNarrow.withUser(eg.user().userId, selfUserId: eg.selfUser.userId), + messages: []); + check(controller).isNotNull().contentFocusNode.hasFocus.isTrue(); + }); + }); + group('ComposeBoxTheme', () { test('lerp light to dark, no crash', () { final a = ComposeBoxTheme.light; From 7bc258b6015725dba4202701aea83cbffd39951b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 1 May 2025 20:11:29 -0700 Subject: [PATCH 146/290] msglist: Call fetchNewer when near bottom --- lib/widgets/message_list.dart | 3 +++ test/widgets/message_list_test.dart | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index aa9684b7f2..66fdec5b5c 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -568,6 +568,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat // still not yet updated to account for the newly-added messages. model.fetchOlder(); } + if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) { + model.fetchNewer(); + } } void _scrollChanged() { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 3b3b01b323..bd7403928f 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -445,6 +445,10 @@ void main() { }); group('fetch older messages on scroll', () { + // TODO(#1569): test fetch newer messages on scroll, too; + // in particular test it happens even when near top as well as bottom + // (because may have haveOldest true but haveNewest false) + int? itemCount(WidgetTester tester) => findScrollView(tester).semanticChildCount; From c02451f4d6b8cbd279cefbec186a24822ed25280 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 13 May 2025 17:22:10 -0700 Subject: [PATCH 147/290] msglist: Show loading indicator at bottom as well as top --- lib/widgets/message_list.dart | 22 ++++++--- test/widgets/message_list_test.dart | 75 ++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 66fdec5b5c..1c2d177664 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -756,13 +756,21 @@ class _MessageListState extends State with PerAccountStoreAwareStat } Widget _buildEndCap() { - return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TypingStatusWidget(narrow: widget.narrow), - MarkAsReadWidget(narrow: widget.narrow), - // To reinforce that the end of the feed has been reached: - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 - const SizedBox(height: 36), - ]); + if (model.haveNewest) { + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + TypingStatusWidget(narrow: widget.narrow), + // TODO perhaps offer mark-as-read even when not done fetching? + MarkAsReadWidget(narrow: widget.narrow), + // To reinforce that the end of the feed has been reached: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 + const SizedBox(height: 36), + ]); + } else if (model.busyFetchingMore) { + // See [_buildStartCap] for why this condition shows a loading indicator. + return const _MessageListLoadingMore(); + } else { + return SizedBox.shrink(); + } } Widget _buildItem(MessageListItem data) { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index bd7403928f..8cc216077a 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -59,6 +59,7 @@ void main() { bool foundOldest = true, int? messageCount, List? messages, + GetMessagesResult? fetchResult, List? streams, List? users, List? subscriptions, @@ -83,12 +84,17 @@ void main() { // prepare message list data await store.addUser(eg.selfUser); await store.addUsers(users ?? []); - assert((messageCount == null) != (messages == null)); - messages ??= List.generate(messageCount!, (index) { - return eg.streamMessage(sender: eg.selfUser); - }); - connection.prepare(json: - eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); + if (fetchResult != null) { + assert(foundOldest && messageCount == null && messages == null); + } else { + assert((messageCount == null) != (messages == null)); + messages ??= List.generate(messageCount!, (index) { + return eg.streamMessage(sender: eg.selfUser); + }); + fetchResult = eg.newestGetMessagesResult( + foundOldest: foundOldest, messages: messages); + } + connection.prepare(json: fetchResult.toJson()); await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, skipAssertAccountExists: skipAssertAccountExists, @@ -696,6 +702,63 @@ void main() { }); }); + // TODO test markers at start of list (`_buildStartCap`) + + group('markers at end of list', () { + final findLoadingIndicator = find.byType(CircularProgressIndicator); + + testWidgets('spacer when have newest', (tester) async { + final messages = List.generate(10, + (i) => eg.streamMessage(content: '

message $i

')); + await setupMessageListPage(tester, narrow: CombinedFeedNarrow(), + fetchResult: eg.nearGetMessagesResult(anchor: messages.last.id, + foundOldest: true, foundNewest: true, messages: messages)); + check(findMessageListScrollController(tester)!.position) + .extentAfter.equals(0); + + // There's no loading indicator. + check(findLoadingIndicator).findsNothing(); + // The last message is spaced above the bottom of the viewport. + check(tester.getRect(find.text('message 9'))) + .bottom..isGreaterThan(400)..isLessThan(570); + }); + + testWidgets('loading indicator displaces spacer etc.', (tester) async { + await setupMessageListPage(tester, narrow: CombinedFeedNarrow(), + skipPumpAndSettle: true, + // TODO(#1569) fix realism of this data: foundNewest false should mean + // some messages found after anchor (and then we might need to scroll + // to cause fetching newer messages). + fetchResult: eg.nearGetMessagesResult(anchor: 1000, + foundOldest: true, foundNewest: false, + messages: List.generate(10, + (i) => eg.streamMessage(id: 100 + i, content: '

message $i

')))); + await tester.pump(); + + // The message list will immediately start fetching newer messages. + connection.prepare(json: eg.newerGetMessagesResult( + anchor: 109, foundNewest: true, messages: List.generate(100, + (i) => eg.streamMessage(id: 110 + i))).toJson()); + await tester.pump(Duration(milliseconds: 10)); + await tester.pump(); + + // There's a loading indicator. + check(findLoadingIndicator).findsOne(); + // It's at the bottom. + check(findMessageListScrollController(tester)!.position) + .extentAfter.equals(0); + final loadingIndicatorRect = tester.getRect(findLoadingIndicator); + check(loadingIndicatorRect).bottom.isGreaterThan(575); + // The last message is shortly above it; no spacer or anything else. + check(tester.getRect(find.text('message 9'))) + .bottom.isGreaterThan(loadingIndicatorRect.top - 36); // TODO(#1569) where's this space going? + await tester.pumpAndSettle(); + }); + + // TODO(#1569) test no typing status or mark-read button when not haveNewest + // (even without loading indicator) + }); + group('TypingStatusWidget', () { final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser]; final finder = find.descendant( From 48abb5c257c340d974b87cb6f865b68a5ffed72a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 12 May 2025 20:19:30 -0700 Subject: [PATCH 148/290] msglist: Accept anchor on MessageListPage This is NFC as to the live app, because nothing yet passes this argument. --- lib/widgets/message_list.dart | 39 +++++++++++++++++++++-------- test/widgets/message_list_test.dart | 2 ++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 1c2d177664..1c43db9583 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -141,12 +141,17 @@ abstract class MessageListPageState { } class MessageListPage extends StatefulWidget { - const MessageListPage({super.key, required this.initNarrow}); + const MessageListPage({ + super.key, + required this.initNarrow, + this.initAnchorMessageId, + }); static AccountRoute buildRoute({int? accountId, BuildContext? context, - required Narrow narrow}) { + required Narrow narrow, int? initAnchorMessageId}) { return MaterialAccountWidgetRoute(accountId: accountId, context: context, - page: MessageListPage(initNarrow: narrow)); + page: MessageListPage( + initNarrow: narrow, initAnchorMessageId: initAnchorMessageId)); } /// The [MessageListPageState] above this context in the tree. @@ -162,6 +167,7 @@ class MessageListPage extends StatefulWidget { } final Narrow initNarrow; + final int? initAnchorMessageId; // TODO(#1564) highlight target upon load @override State createState() => _MessageListPageState(); @@ -240,6 +246,10 @@ class _MessageListPageState extends State implements MessageLis actions.add(_TopicListButton(streamId: streamId)); } + // TODO(#80): default to anchor firstUnread, instead of newest + final initAnchor = widget.initAnchorMessageId == null + ? AnchorCode.newest : NumericAnchor(widget.initAnchorMessageId!); + // Insert a PageRoot here, to provide a context that can be used for // MessageListPage.ancestorOf. return PageRoot(child: Scaffold( @@ -259,7 +269,8 @@ class _MessageListPageState extends State implements MessageLis // we matched to the Figma in 21dbae120. See another frame, which uses that: // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev body: Builder( - builder: (BuildContext context) => Column( + builder: (BuildContext context) { + return Column( // Children are expected to take the full horizontal space // and handle the horizontal device insets. // The bottom inset should be handled by the last child only. @@ -279,11 +290,13 @@ class _MessageListPageState extends State implements MessageLis child: MessageList( key: _messageListKey, narrow: narrow, + initAnchor: initAnchor, onNarrowChanged: _narrowChanged, ))), if (ComposeBox.hasComposeBox(narrow)) ComposeBox(key: _composeBoxKey, narrow: narrow) - ])))); + ]); + }))); } } @@ -479,9 +492,15 @@ const kFetchMessagesBufferPixels = (kMessageListFetchBatchSize / 2) * _kShortMes /// When there is no [ComposeBox], also takes responsibility /// for dealing with the bottom inset. class MessageList extends StatefulWidget { - const MessageList({super.key, required this.narrow, required this.onNarrowChanged}); + const MessageList({ + super.key, + required this.narrow, + required this.initAnchor, + required this.onNarrowChanged, + }); final Narrow narrow; + final Anchor initAnchor; final void Function(Narrow newNarrow) onNarrowChanged; @override @@ -504,8 +523,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages + final anchor = _model == null ? widget.initAnchor : _model!.anchor; _model?.dispose(); - _initModel(PerAccountStoreWidget.of(context)); + _initModel(PerAccountStoreWidget.of(context), anchor); } @override @@ -516,10 +536,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat super.dispose(); } - void _initModel(PerAccountStore store) { - // TODO(#82): get anchor as page/route argument, instead of using newest - // TODO(#80): default to anchor firstUnread, instead of newest - final anchor = AnchorCode.newest; + void _initModel(PerAccountStore store, Anchor anchor) { _model = MessageListView.init(store: store, narrow: widget.narrow, anchor: anchor); model.addListener(_modelChanged); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 8cc216077a..a1851f8001 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -374,6 +374,8 @@ void main() { }); group('fetch initial batch of messages', () { + // TODO(#1569): test effect of initAnchorMessageId + group('topic permalink', () { final someStream = eg.stream(); const someTopic = 'some topic'; From b1730aeb142453312a884050a6babdeda1a2649b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 8 May 2025 17:09:43 -0700 Subject: [PATCH 149/290] msglist: Jump, not scroll, to end when it might be far When the message list is truly far back in history -- for example, at first unread in the combined feed or a busy channel, for a user who has some old unreads going back months and years -- trying to scroll smoothly to the bottom is hopeless. The only way to get to the newest messages in any reasonable amount of time is to jump there. So, do that. --- lib/model/message_list.dart | 16 ++++++++++++- lib/widgets/message_list.dart | 35 ++++++++++++++++++++++++++--- test/model/message_list_test.dart | 2 ++ test/widgets/message_list_test.dart | 4 ++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 2617f18b68..458478f248 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -479,7 +479,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// which might be made internally by this class in order to /// fetch the messages from scratch, e.g. after certain events. Anchor get anchor => _anchor; - final Anchor _anchor; + Anchor _anchor; void _register() { store.registerMessageList(this); @@ -756,6 +756,20 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Reset this view to start from the newest messages. + /// + /// This will set [anchor] to [AnchorCode.newest], + /// and cause messages to be re-fetched from scratch. + void jumpToEnd() { + assert(fetched); + assert(!haveNewest); + assert(anchor != AnchorCode.newest); + _anchor = AnchorCode.newest; + _reset(); + notifyListeners(); + fetchInitial(); + } + /// Add [outboxMessage] if it belongs to the view. void addOutboxMessage(OutboxMessage outboxMessage) { // TODO(#1441) implement this diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 1c43db9583..e1c24249c5 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -554,6 +554,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat // redirected us to the new location of the operand message ID. widget.onNarrowChanged(model.narrow); } + // TODO when model reset, reset scroll setState(() { // The actual state lives in the [MessageListView] model. // This method was called because that just changed. @@ -638,6 +639,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat // MessageList's dartdoc. child: SafeArea( child: ScrollToBottomButton( + model: model, scrollController: scrollController, visible: _scrollToBottomVisible))), ]))))); @@ -837,13 +839,40 @@ class _MessageListLoadingMore extends StatelessWidget { } class ScrollToBottomButton extends StatelessWidget { - const ScrollToBottomButton({super.key, required this.scrollController, required this.visible}); + const ScrollToBottomButton({ + super.key, + required this.model, + required this.scrollController, + required this.visible, + }); - final ValueNotifier visible; + final MessageListView model; final MessageListScrollController scrollController; + final ValueNotifier visible; void _scrollToBottom() { - scrollController.position.scrollToEnd(); + if (model.haveNewest) { + // Scrolling smoothly from here to the bottom won't require any requests + // to the server. + // It also probably isn't *that* far away: the user must have scrolled + // here from there (or from near enough that a fetch reached there), + // so scrolling back there -- at top speed -- shouldn't take too long. + // Go for it. + scrollController.position.scrollToEnd(); + } else { + // This message list doesn't have the messages for the bottom of history. + // There could be quite a lot of history between here and there -- + // for example, at first unread in the combined feed or a busy channel, + // for a user who has some old unreads going back months and years. + // In that case trying to scroll smoothly to the bottom is hopeless. + // + // Given that there were at least 100 messages between this message list's + // initial anchor and the end of history (or else `fetchInitial` would + // have reached the end at the outset), that situation is very likely. + // Even if the end is close by, it's at least one fetch away. + // Instead of scrolling, jump to the end, which is always just one fetch. + model.jumpToEnd(); + } } @override diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index d58de664d8..0eb30c1cbb 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -566,6 +566,8 @@ void main() { }); }); + // TODO(#1569): test jumpToEnd + group('MessageEvent', () { test('in narrow', () async { final stream = eg.stream(); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index a1851f8001..c8928a13ad 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -375,6 +375,8 @@ void main() { group('fetch initial batch of messages', () { // TODO(#1569): test effect of initAnchorMessageId + // TODO(#1569): test that after jumpToEnd, then new store causing new fetch, + // new post-jump anchor prevails over initAnchorMessageId group('topic permalink', () { final someStream = eg.stream(); @@ -668,6 +670,8 @@ void main() { check(isButtonVisible(tester)).equals(false); }); + // TODO(#1569): test choice of jumpToEnd vs. scrollToEnd + testWidgets('scrolls at reasonable, constant speed', (tester) async { const maxSpeed = 8000.0; const distance = 40000.0; From 17d822f41f9fe7fbbc1f0002920a636555f7f7dd Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 12 May 2025 20:00:30 -0700 Subject: [PATCH 150/290] internal_link [nfc]: Introduce NarrowLink type This will give us room to start returning additional information from parsing the URL, in particular the /near/ operand. --- lib/model/internal_link.dart | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index 749f60698c..8169ccc915 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -109,6 +109,22 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { return result; } +/// The result of parsing some URL within a Zulip realm, +/// when the URL corresponds to some page in this app. +sealed class InternalLink { + InternalLink({required this.realmUrl}); + + final Uri realmUrl; +} + +/// The result of parsing some URL that points to a narrow on a Zulip realm, +/// when the narrow is of a type that this app understands. +class NarrowLink extends InternalLink { + NarrowLink(this.narrow, {required super.realmUrl}); + + final Narrow narrow; +} + /// A [Narrow] from a given URL, on `store`'s realm. /// /// `url` must already be a result from [PerAccountStore.tryResolveUrl] @@ -131,7 +147,7 @@ Narrow? parseInternalLink(Uri url, PerAccountStore store) { switch (category) { case 'narrow': if (segments.isEmpty || !segments.length.isEven) return null; - return _interpretNarrowSegments(segments, store); + return _interpretNarrowSegments(segments, store)?.narrow; } return null; } @@ -155,7 +171,7 @@ bool _isInternalLink(Uri url, Uri realmUrl) { return (category, segments); } -Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { +NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore store) { assert(segments.isNotEmpty); assert(segments.length.isEven); @@ -209,6 +225,7 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { } } + final Narrow? narrow; if (isElementOperands.isNotEmpty) { if (streamElement != null || topicElement != null || dmElement != null || withElement != null) { return null; @@ -216,9 +233,9 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { if (isElementOperands.length > 1) return null; switch (isElementOperands.single) { case IsOperand.mentioned: - return const MentionsNarrow(); + narrow = const MentionsNarrow(); case IsOperand.starred: - return const StarredMessagesNarrow(); + narrow = const StarredMessagesNarrow(); case IsOperand.dm: case IsOperand.private: case IsOperand.alerted: @@ -230,17 +247,20 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { } } else if (dmElement != null) { if (streamElement != null || topicElement != null || withElement != null) return null; - return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId); + narrow = DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId); } else if (streamElement != null) { final streamId = streamElement.operand; if (topicElement != null) { - return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand); + narrow = TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand); } else { if (withElement != null) return null; - return ChannelNarrow(streamId); + narrow = ChannelNarrow(streamId); } + } else { + return null; } - return null; + + return NarrowLink(narrow, realmUrl: store.realmUrl); } @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true) From cb94d4e3ad100ccfbc686170fe11cc1a3c17d7c6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 12 May 2025 20:07:02 -0700 Subject: [PATCH 151/290] internal_link [nfc]: Propagate InternalLink up to caller --- lib/model/internal_link.dart | 14 +++++++++----- lib/widgets/content.dart | 18 ++++++++++-------- test/model/internal_link_test.dart | 20 +++++++++++++++++++- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index 8169ccc915..c5889fb7e5 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -125,29 +125,33 @@ class NarrowLink extends InternalLink { final Narrow narrow; } -/// A [Narrow] from a given URL, on `store`'s realm. +/// Try to parse the given URL as a page in this app, on `store`'s realm. /// /// `url` must already be a result from [PerAccountStore.tryResolveUrl] /// on `store`. /// -/// Returns `null` if any of the operator/operand pairs are invalid. +/// Returns null if the URL isn't on this realm, +/// or isn't a valid Zulip URL, +/// or isn't currently supported as leading to a page in this app. /// +/// In particular this will return null if `url` is a `/#narrow/…` URL +/// and any of the operator/operand pairs are invalid. /// Since narrow links can combine operators in ways our [Narrow] type can't /// represent, this can also return null for valid narrow links. /// /// This can also return null for some valid narrow links that our Narrow /// type *could* accurately represent. We should try to understand these -/// better, but some kinds will be rare, even unheard-of: +/// better, but some kinds will be rare, even unheard-of. For example: /// #narrow/stream/1-announce/stream/1-announce (duplicated operator) // TODO(#252): handle all valid narrow links, returning a search narrow -Narrow? parseInternalLink(Uri url, PerAccountStore store) { +InternalLink? parseInternalLink(Uri url, PerAccountStore store) { if (!_isInternalLink(url, store.realmUrl)) return null; final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment); switch (category) { case 'narrow': if (segments.isEmpty || !segments.length.isEven) return null; - return _interpretNarrowSegments(segments, store)?.narrow; + return _interpretNarrowSegments(segments, store); } return null; } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 62801ab867..cb8af4ce6d 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1536,15 +1536,17 @@ void _launchUrl(BuildContext context, String urlString) async { return; } - final internalNarrow = parseInternalLink(url, store); - if (internalNarrow != null) { - unawaited(Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: internalNarrow))); - return; + final internalLink = parseInternalLink(url, store); + assert(internalLink == null || internalLink.realmUrl == store.realmUrl); + switch (internalLink) { + case NarrowLink(): + unawaited(Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: internalLink.narrow))); + + case null: + await PlatformActions.launchUrl(context, url); } - - await PlatformActions.launchUrl(context, url); } /// Like [Image.network], but includes [authHeader] if [src] is on-realm. diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart index 611cc3ece0..7f1046fee8 100644 --- a/test/model/internal_link_test.dart +++ b/test/model/internal_link_test.dart @@ -160,7 +160,14 @@ void main() { test(urlString, () async { final store = await setupStore(realmUrl: realmUrl, streams: streams, users: users); final url = store.tryResolveUrl(urlString)!; - check(parseInternalLink(url, store)).equals(expected); + final result = parseInternalLink(url, store); + if (expected == null) { + check(result).isNull(); + } else { + check(result).isA() + ..realmUrl.equals(realmUrl) + ..narrow.equals(expected); + } }); } } @@ -258,6 +265,9 @@ void main() { final url = store.tryResolveUrl(urlString)!; final result = parseInternalLink(url, store); check(result != null).equals(expected); + if (result != null) { + check(result).realmUrl.equals(realmUrl); + } }); } } @@ -564,3 +574,11 @@ void main() { }); }); } + +extension InternalLinkChecks on Subject { + Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); +} + +extension NarrowLinkChecks on Subject { + Subject get narrow => has((x) => x.narrow, 'narrow'); +} From aab50893a4fc089cc7d282c6b69c2d0eae1c76c5 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 12 May 2025 20:11:15 -0700 Subject: [PATCH 152/290] internal_link: Parse /near/ in narrow links --- lib/model/internal_link.dart | 13 +++++++++---- test/model/internal_link_test.dart | 2 ++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index c5889fb7e5..a552f1679f 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -120,9 +120,10 @@ sealed class InternalLink { /// The result of parsing some URL that points to a narrow on a Zulip realm, /// when the narrow is of a type that this app understands. class NarrowLink extends InternalLink { - NarrowLink(this.narrow, {required super.realmUrl}); + NarrowLink(this.narrow, this.nearMessageId, {required super.realmUrl}); final Narrow narrow; + final int? nearMessageId; } /// Try to parse the given URL as a page in this app, on `store`'s realm. @@ -184,6 +185,7 @@ NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore stor ApiNarrowDm? dmElement; ApiNarrowWith? withElement; Set isElementOperands = {}; + int? nearMessageId; for (var i = 0; i < segments.length; i += 2) { final (operator, negated) = _parseOperator(segments[i]); @@ -221,8 +223,11 @@ NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore stor // It is fine to have duplicates of the same [IsOperand]. isElementOperands.add(IsOperand.fromRawString(operand)); - case _NarrowOperator.near: // TODO(#82): support for near - continue; + case _NarrowOperator.near: + if (nearMessageId != null) return null; + final messageId = int.tryParse(operand, radix: 10); + if (messageId == null) return null; + nearMessageId = messageId; case _NarrowOperator.unknown: return null; @@ -264,7 +269,7 @@ NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore stor return null; } - return NarrowLink(narrow, realmUrl: store.realmUrl); + return NarrowLink(narrow, nearMessageId, realmUrl: store.realmUrl); } @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true) diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart index 7f1046fee8..824def8cc2 100644 --- a/test/model/internal_link_test.dart +++ b/test/model/internal_link_test.dart @@ -380,6 +380,8 @@ void main() { } }); + // TODO(#1570): test parsing /near/ operator + group('unexpected link shapes are rejected', () { final testCases = [ ('/#narrow/stream/name/topic/', null), // missing operand From 8cb2c7bdce57ae5d41cea24931a16b1e28c4dc6e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 12 May 2025 20:20:19 -0700 Subject: [PATCH 153/290] internal_link: Open /near/ links at the given anchor in msglist Fixes #82. One TODO comment for this issue referred to an aspect I'm leaving out of scope for now, namely when opening a notification rather than an internal Zulip link. So I've filed a separate issue #1565 for that, and this updates that comment to point there. --- lib/notifications/display.dart | 2 +- lib/widgets/content.dart | 3 ++- test/widgets/content_test.dart | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 081a2cf633..6e2585e135 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -513,7 +513,7 @@ class NotificationDisplayManager { return MessageListPage.buildRoute( accountId: account.id, - // TODO(#82): Open at specific message, not just conversation + // TODO(#1565): Open at specific message, not just conversation narrow: payload.narrow); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index cb8af4ce6d..44411434fd 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1542,7 +1542,8 @@ void _launchUrl(BuildContext context, String urlString) async { case NarrowLink(): unawaited(Navigator.push(context, MessageListPage.buildRoute(context: context, - narrow: internalLink.narrow))); + narrow: internalLink.narrow, + initAnchorMessageId: internalLink.nearMessageId))); case null: await PlatformActions.launchUrl(context, url); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index a788225aac..b5150a54ee 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1032,6 +1032,8 @@ void main() { .page.isA().initNarrow.equals(const ChannelNarrow(1)); }); + // TODO(#1570): test links with /near/ go to the specific message + testWidgets('invalid internal links are opened in browser', (tester) async { // Link is invalid due to `topic` operator missing an operand. final pushedRoutes = await prepare(tester, From d34f3b3384294ddf0a7a6d8e91881ccbfc2e89f6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 11 Jun 2025 21:14:05 -0700 Subject: [PATCH 154/290] msglist: Fetch at first unread, optionally, instead of newest Fixes #80. This differs from the behavior of the legacy mobile app, and the original plan for #80: instead of always using the first unread, by default we use the first unread only for conversation narrows, and stick with the newest message for interleaved narrows. The reason is that once I implemented this behavior (for all narrows) and started trying it out, I immediately found that it was a lot slower than fetching the newest message any time I went to the combined feed, or to some channel feeds -- anywhere that I have a large number of unreads, it seems. The request to the server can take several seconds to complete. In retrospect it's unsurprising that this might be a naturally slower request for the database to handle. In any case, in a view where I have lots of accumulated old unreads, I don't really want to start from the first unread anyway: I want to look at the newest history, and perhaps scroll back a bit from there, to see the messages that are relevant now. OTOH for someone who makes a practice of keeping their Zulip unreads clear, the first unread will be relevant now, and they'll likely want to start from there even in interleaved views. So make it a setting. See also chat thread: https://chat.zulip.org/#narrow/channel/48-mobile/topic/opening.20thread.20at.20end/near/2192303 --- assets/l10n/app_en.arb | 20 + lib/generated/l10n/zulip_localizations.dart | 30 + .../l10n/zulip_localizations_ar.dart | 17 + .../l10n/zulip_localizations_de.dart | 17 + .../l10n/zulip_localizations_en.dart | 17 + .../l10n/zulip_localizations_ja.dart | 17 + .../l10n/zulip_localizations_nb.dart | 17 + .../l10n/zulip_localizations_pl.dart | 17 + .../l10n/zulip_localizations_ru.dart | 17 + .../l10n/zulip_localizations_sk.dart | 17 + .../l10n/zulip_localizations_uk.dart | 17 + .../l10n/zulip_localizations_zh.dart | 17 + lib/model/database.dart | 9 +- lib/model/database.g.dart | 119 ++- lib/model/schema_versions.g.dart | 82 ++ lib/model/settings.dart | 51 +- lib/widgets/message_list.dart | 11 +- lib/widgets/settings.dart | 65 ++ test/model/schemas/drift_schema_v7.json | 1 + test/model/schemas/schema.dart | 5 +- test/model/schemas/schema_v7.dart | 942 ++++++++++++++++++ test/model/settings_test.dart | 3 + test/widgets/message_list_test.dart | 5 +- test/widgets/settings_test.dart | 2 + 24 files changed, 1502 insertions(+), 13 deletions(-) create mode 100644 test/model/schemas/drift_schema_v7.json create mode 100644 test/model/schemas/schema_v7.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index fb62815eef..1f070feb19 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -951,6 +951,26 @@ "@pollWidgetOptionsMissing": { "description": "Text to display for a poll when it has no options" }, + "initialAnchorSettingTitle": "Open message feeds at", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "You can choose whether message feeds open at your first unread message or at the newest messages.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "First unread message", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "First unread message in single conversations, newest message elsewhere", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "Newest message", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, "experimentalFeatureSettingsPageTitle": "Experimental features", "@experimentalFeatureSettingsPageTitle": { "description": "Title of settings page for experimental, in-development features" diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index c990e54155..28f4eee3ba 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1415,6 +1415,36 @@ abstract class ZulipLocalizations { /// **'This poll has no options yet.'** String get pollWidgetOptionsMissing; + /// Title of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'Open message feeds at'** + String get initialAnchorSettingTitle; + + /// Description of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'You can choose whether message feeds open at your first unread message or at the newest messages.'** + String get initialAnchorSettingDescription; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'First unread message'** + String get initialAnchorSettingFirstUnreadAlways; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'First unread message in single conversations, newest message elsewhere'** + String get initialAnchorSettingFirstUnreadConversations; + + /// Label for a value of setting controlling initial anchor of message list. + /// + /// In en, this message translates to: + /// **'Newest message'** + String get initialAnchorSettingNewestAlways; + /// Title of settings page for experimental, in-development features /// /// 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 50f55ee551..104cc16822 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -773,6 +773,23 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 43a5becc51..0abb3c2e55 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -773,6 +773,23 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index de74e5d0e7..d5201bad84 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -773,6 +773,23 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 3d96c28e2f..69a2e97816 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -773,6 +773,23 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 029c385574..66198f6a40 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -773,6 +773,23 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0752efa496..b0189c01fc 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -784,6 +784,23 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'Ta sonda nie ma opcji do wyboru.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Funkcje eksperymentalne'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 22d2d7a337..063b3c01e4 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -787,6 +787,23 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'В опросе пока нет вариантов ответа.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Экспериментальные функции'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 132a5355e9..ec7a8f36e4 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -775,6 +775,23 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index d76cbec6d9..af57ca0f86 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -787,6 +787,23 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get pollWidgetOptionsMissing => 'У цьому опитуванні ще немає варіантів.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Експериментальні функції'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 4e74b4c95c..3b425dcea1 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -773,6 +773,23 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/model/database.dart b/lib/model/database.dart index 57910e7a50..f7d85b4b95 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -24,6 +24,9 @@ class GlobalSettings extends Table { Column get browserPreference => textEnum() .nullable()(); + Column get visitFirstUnread => textEnum() + .nullable()(); + // If adding a new column to this table, consider whether [BoolGlobalSettings] // can do the job instead (by adding a value to the [BoolGlobalSetting] enum). // That way is more convenient, when it works, because @@ -119,7 +122,7 @@ class AppDatabase extends _$AppDatabase { // information on using the build_runner. // * Write a migration in `_migrationSteps` below. // * Write tests. - static const int latestSchemaVersion = 6; // See note. + static const int latestSchemaVersion = 7; // See note. @override int get schemaVersion => latestSchemaVersion; @@ -174,6 +177,10 @@ class AppDatabase extends _$AppDatabase { from5To6: (m, schema) async { await m.createTable(schema.boolGlobalSettings); }, + from6To7: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.visitFirstUnread); + }, ); Future _createLatestSchema(Migrator m) async { diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart index 99752bdd62..19c9f35c5a 100644 --- a/lib/model/database.g.dart +++ b/lib/model/database.g.dart @@ -32,7 +32,22 @@ class $GlobalSettingsTable extends GlobalSettings $GlobalSettingsTable.$converterbrowserPreferencen, ); @override - List get $columns => [themeSetting, browserPreference]; + late final GeneratedColumnWithTypeConverter + visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$convertervisitFirstUnreadn, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -57,6 +72,13 @@ class $GlobalSettingsTable extends GlobalSettings data['${effectivePrefix}browser_preference'], ), ), + visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + ), ); } @@ -81,13 +103,26 @@ class $GlobalSettingsTable extends GlobalSettings $converterbrowserPreferencen = JsonTypeConverter2.asNullable( $converterbrowserPreference, ); + static JsonTypeConverter2 + $convertervisitFirstUnread = const EnumNameConverter( + VisitFirstUnreadSetting.values, + ); + static JsonTypeConverter2 + $convertervisitFirstUnreadn = JsonTypeConverter2.asNullable( + $convertervisitFirstUnread, + ); } class GlobalSettingsData extends DataClass implements Insertable { final ThemeSetting? themeSetting; final BrowserPreference? browserPreference; - const GlobalSettingsData({this.themeSetting, this.browserPreference}); + final VisitFirstUnreadSetting? visitFirstUnread; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -103,6 +138,13 @@ class GlobalSettingsData extends DataClass ), ); } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toSql( + visitFirstUnread, + ), + ); + } return map; } @@ -116,6 +158,10 @@ class GlobalSettingsData extends DataClass browserPreference == null && nullToAbsent ? const Value.absent() : Value(browserPreference), + visitFirstUnread: + visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), ); } @@ -130,6 +176,8 @@ class GlobalSettingsData extends DataClass ), browserPreference: $GlobalSettingsTable.$converterbrowserPreferencen .fromJson(serializer.fromJson(json['browserPreference'])), + visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn + .fromJson(serializer.fromJson(json['visitFirstUnread'])), ); } @override @@ -144,18 +192,28 @@ class GlobalSettingsData extends DataClass browserPreference, ), ), + 'visitFirstUnread': serializer.toJson( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toJson( + visitFirstUnread, + ), + ), }; } GlobalSettingsData copyWith({ Value themeSetting = const Value.absent(), Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, browserPreference: browserPreference.present ? browserPreference.value : this.browserPreference, + visitFirstUnread: + visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( @@ -167,6 +225,10 @@ class GlobalSettingsData extends DataClass data.browserPreference.present ? data.browserPreference.value : this.browserPreference, + visitFirstUnread: + data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, ); } @@ -174,43 +236,51 @@ class GlobalSettingsData extends DataClass String toString() { return (StringBuffer('GlobalSettingsData(') ..write('themeSetting: $themeSetting, ') - ..write('browserPreference: $browserPreference') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(themeSetting, browserPreference); + int get hashCode => + Object.hash(themeSetting, browserPreference, visitFirstUnread); @override bool operator ==(Object other) => identical(this, other) || (other is GlobalSettingsData && other.themeSetting == this.themeSetting && - other.browserPreference == this.browserPreference); + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread); } class GlobalSettingsCompanion extends UpdateCompanion { final Value themeSetting; final Value browserPreference; + final Value visitFirstUnread; final Value rowid; const GlobalSettingsCompanion({ this.themeSetting = const Value.absent(), this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), this.rowid = const Value.absent(), }); GlobalSettingsCompanion.insert({ this.themeSetting = const Value.absent(), this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), this.rowid = const Value.absent(), }); static Insertable custom({ Expression? themeSetting, Expression? browserPreference, + Expression? visitFirstUnread, Expression? rowid, }) { return RawValuesInsertable({ if (themeSetting != null) 'theme_setting': themeSetting, if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, if (rowid != null) 'rowid': rowid, }); } @@ -218,11 +288,13 @@ class GlobalSettingsCompanion extends UpdateCompanion { GlobalSettingsCompanion copyWith({ Value? themeSetting, Value? browserPreference, + Value? visitFirstUnread, Value? rowid, }) { return GlobalSettingsCompanion( themeSetting: themeSetting ?? this.themeSetting, browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, rowid: rowid ?? this.rowid, ); } @@ -242,6 +314,13 @@ class GlobalSettingsCompanion extends UpdateCompanion { ), ); } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable( + $GlobalSettingsTable.$convertervisitFirstUnreadn.toSql( + visitFirstUnread.value, + ), + ); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -253,6 +332,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { return (StringBuffer('GlobalSettingsCompanion(') ..write('themeSetting: $themeSetting, ') ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -1109,12 +1189,14 @@ typedef $$GlobalSettingsTableCreateCompanionBuilder = GlobalSettingsCompanion Function({ Value themeSetting, Value browserPreference, + Value visitFirstUnread, Value rowid, }); typedef $$GlobalSettingsTableUpdateCompanionBuilder = GlobalSettingsCompanion Function({ Value themeSetting, Value browserPreference, + Value visitFirstUnread, Value rowid, }); @@ -1138,6 +1220,16 @@ class $$GlobalSettingsTableFilterComposer column: $table.browserPreference, builder: (column) => ColumnWithTypeConverterFilters(column), ); + + ColumnWithTypeConverterFilters< + VisitFirstUnreadSetting?, + VisitFirstUnreadSetting, + String + > + get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); } class $$GlobalSettingsTableOrderingComposer @@ -1158,6 +1250,11 @@ class $$GlobalSettingsTableOrderingComposer column: $table.browserPreference, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => ColumnOrderings(column), + ); } class $$GlobalSettingsTableAnnotationComposer @@ -1180,6 +1277,12 @@ class $$GlobalSettingsTableAnnotationComposer column: $table.browserPreference, builder: (column) => column, ); + + GeneratedColumnWithTypeConverter + get visitFirstUnread => $composableBuilder( + column: $table.visitFirstUnread, + builder: (column) => column, + ); } class $$GlobalSettingsTableTableManager @@ -1226,10 +1329,13 @@ class $$GlobalSettingsTableTableManager Value themeSetting = const Value.absent(), Value browserPreference = const Value.absent(), + Value visitFirstUnread = + const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion( themeSetting: themeSetting, browserPreference: browserPreference, + visitFirstUnread: visitFirstUnread, rowid: rowid, ), createCompanionCallback: @@ -1237,10 +1343,13 @@ class $$GlobalSettingsTableTableManager Value themeSetting = const Value.absent(), Value browserPreference = const Value.absent(), + Value visitFirstUnread = + const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion.insert( themeSetting: themeSetting, browserPreference: browserPreference, + visitFirstUnread: visitFirstUnread, rowid: rowid, ), withReferenceMapper: diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart index 9bfa74f627..4fcfc67a06 100644 --- a/lib/model/schema_versions.g.dart +++ b/lib/model/schema_versions.g.dart @@ -367,12 +367,87 @@ i1.GeneratedColumn _column_12(String aliasedName) => 'CHECK ("value" IN (0, 1))', ), ); + +final class Schema7 extends i0.VersionedSchema { + Schema7({required super.database}) : super(version: 7); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape4 globalSettings = Shape4( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + 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 Shape4 extends i0.VersionedTable { + Shape4({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_13(String aliasedName) => + i1.GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, + required Future Function(i1.Migrator m, Schema7 schema) from6To7, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -401,6 +476,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from5To6(migrator, schema); return 6; + case 6: + final schema = Schema7(database: database); + final migrator = i1.Migrator(database, schema); + await from6To7(migrator, schema); + return 7; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -413,6 +493,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, + required Future Function(i1.Migrator m, Schema7 schema) from6To7, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -420,5 +501,6 @@ i1.OnUpgrade stepByStep({ from3To4: from3To4, from4To5: from4To5, from5To6: from5To6, + from6To7: from6To7, ), ); diff --git a/lib/model/settings.dart b/lib/model/settings.dart index 5fd2fec9f8..d3393292a6 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../generated/l10n/zulip_localizations.dart'; import 'binding.dart'; import 'database.dart'; +import 'narrow.dart'; import 'store.dart'; /// The user's choice of visual theme for the app. @@ -45,6 +46,27 @@ enum BrowserPreference { external, } +/// The user's choice of when to open a message list at their first unread, +/// rather than at the newest message. +/// +/// This setting has no effect when navigating to a specific message: +/// in that case the message list opens at that message, +/// regardless of this setting. +enum VisitFirstUnreadSetting { + /// Always go to the first unread, rather than the newest message. + always, + + /// Go to the first unread in conversations, + /// and the newest in interleaved views. + conversations, + + /// Always go to the newest message, rather than the first unread. + never; + + /// The effective value of this setting if the user hasn't set it. + static VisitFirstUnreadSetting _default = conversations; +} + /// A general category of account-independent setting the user might set. /// /// Different kinds of settings call for different treatment in the UI, @@ -119,7 +141,7 @@ enum BoolGlobalSetting { // Former settings which might exist in the database, // whose names should therefore not be reused: - // (this list is empty so far) + // openFirstUnread // v0.0.30 ; const BoolGlobalSetting(this.type, this.default_); @@ -228,6 +250,33 @@ class GlobalSettingsStore extends ChangeNotifier { } } + /// The user's choice of [VisitFirstUnreadSetting], applying our default. + /// + /// See also [shouldVisitFirstUnread] and [setVisitFirstUnread]. + VisitFirstUnreadSetting get visitFirstUnread { + return _data.visitFirstUnread ?? VisitFirstUnreadSetting._default; + } + + /// Set [visitFirstUnread], persistently for future runs of the app. + Future setVisitFirstUnread(VisitFirstUnreadSetting value) async { + await _update(GlobalSettingsCompanion(visitFirstUnread: Value(value))); + } + + /// The value that [visitFirstUnread] works out to for the given narrow. + bool shouldVisitFirstUnread({required Narrow narrow}) { + return switch (visitFirstUnread) { + VisitFirstUnreadSetting.always => true, + VisitFirstUnreadSetting.never => false, + VisitFirstUnreadSetting.conversations => switch (narrow) { + TopicNarrow() || DmNarrow() + => true, + CombinedFeedNarrow() || ChannelNarrow() + || MentionsNarrow() || StarredMessagesNarrow() + => false, + }, + }; + } + /// The user's choice of the given bool-valued setting, or our default for it. /// /// See also [setBool]. diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index e1c24249c5..513043fea6 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -246,9 +246,14 @@ class _MessageListPageState extends State implements MessageLis actions.add(_TopicListButton(streamId: streamId)); } - // TODO(#80): default to anchor firstUnread, instead of newest - final initAnchor = widget.initAnchorMessageId == null - ? AnchorCode.newest : NumericAnchor(widget.initAnchorMessageId!); + final Anchor initAnchor; + if (widget.initAnchorMessageId != null) { + initAnchor = NumericAnchor(widget.initAnchorMessageId!); + } else { + final globalSettings = GlobalStoreWidget.settingsOf(context); + final useFirstUnread = globalSettings.shouldVisitFirstUnread(narrow: narrow); + initAnchor = useFirstUnread ? AnchorCode.firstUnread : AnchorCode.newest; + } // Insert a PageRoot here, to provide a context that can be used for // MessageListPage.ancestorOf. diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index a96fb82928..449be11313 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -23,6 +23,7 @@ class SettingsPage extends StatelessWidget { body: Column(children: [ const _ThemeSetting(), const _BrowserPreferenceSetting(), + const _VisitFirstUnreadSetting(), if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty) ListTile( title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), @@ -85,6 +86,70 @@ class _BrowserPreferenceSetting extends StatelessWidget { } } +class _VisitFirstUnreadSetting extends StatelessWidget { + const _VisitFirstUnreadSetting(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return ListTile( + title: Text(zulipLocalizations.initialAnchorSettingTitle), + subtitle: Text(VisitFirstUnreadSettingPage._valueDisplayName( + globalSettings.visitFirstUnread, zulipLocalizations: zulipLocalizations)), + onTap: () => Navigator.push(context, + VisitFirstUnreadSettingPage.buildRoute())); + } +} + +class VisitFirstUnreadSettingPage extends StatelessWidget { + const VisitFirstUnreadSettingPage({super.key}); + + static WidgetRoute buildRoute() { + return MaterialWidgetRoute(page: const VisitFirstUnreadSettingPage()); + } + + static String _valueDisplayName(VisitFirstUnreadSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + VisitFirstUnreadSetting.always => + zulipLocalizations.initialAnchorSettingFirstUnreadAlways, + VisitFirstUnreadSetting.conversations => + zulipLocalizations.initialAnchorSettingFirstUnreadConversations, + VisitFirstUnreadSetting.never => + zulipLocalizations.initialAnchorSettingNewestAlways, + }; + } + + void _handleChange(BuildContext context, VisitFirstUnreadSetting? value) { + if (value == null) return; // TODO(log); can this actually happen? how? + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setVisitFirstUnread(value); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return Scaffold( + appBar: AppBar(title: Text(zulipLocalizations.initialAnchorSettingTitle)), + body: Column(children: [ + ListTile(title: Text(zulipLocalizations.initialAnchorSettingDescription)), + for (final value in VisitFirstUnreadSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + value: value, + // TODO(#1545) stop using the deprecated members + // ignore: deprecated_member_use + groupValue: globalSettings.visitFirstUnread, + // ignore: deprecated_member_use + onChanged: (newValue) => _handleChange(context, newValue)), + ])); + } +} + class ExperimentalFeaturesPage extends StatelessWidget { const ExperimentalFeaturesPage({super.key}); diff --git a/test/model/schemas/drift_schema_v7.json b/test/model/schemas/drift_schema_v7.json new file mode 100644 index 0000000000..28ceaac619 --- /dev/null +++ b/test/model/schemas/drift_schema_v7.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart index d59002bf56..87de9194d3 100644 --- a/test/model/schemas/schema.dart +++ b/test/model/schemas/schema.dart @@ -9,6 +9,7 @@ import 'schema_v3.dart' as v3; import 'schema_v4.dart' as v4; import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; +import 'schema_v7.dart' as v7; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -26,10 +27,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v5.DatabaseAtV5(db); case 6: return v6.DatabaseAtV6(db); + case 7: + return v7.DatabaseAtV7(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6]; + static const versions = const [1, 2, 3, 4, 5, 6, 7]; } diff --git a/test/model/schemas/schema_v7.dart b/test/model/schemas/schema_v7.dart new file mode 100644 index 0000000000..dd3951b800 --- /dev/null +++ b/test/model/schemas/schema_v7.dart @@ -0,0 +1,942 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: + themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: + browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: + visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: + browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: + visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: + data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: + data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: + data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(themeSetting, browserPreference, visitFirstUnread); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: + attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: + zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: + ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: + zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: + ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: + data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: + data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: + data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: + data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV7 extends GeneratedDatabase { + DatabaseAtV7(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 7; +} diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart index ad739f5d4b..89956323e2 100644 --- a/test/model/settings_test.dart +++ b/test/model/settings_test.dart @@ -77,6 +77,9 @@ void main() { // TODO integration tests with sqlite }); + // TODO(#1571) test visitFirstUnread applies default + // TODO(#1571) test shouldVisitFirstUnread + group('getBool/setBool', () { test('get from default', () { final globalSettings = eg.globalStore(boolGlobalSettings: {}).settings; diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index c8928a13ad..5ba2712e76 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -374,6 +374,7 @@ void main() { }); group('fetch initial batch of messages', () { + // TODO(#1571): test effect of visitFirstUnread setting // TODO(#1569): test effect of initAnchorMessageId // TODO(#1569): test that after jumpToEnd, then new store causing new fetch, // new post-jump anchor prevails over initAnchorMessageId @@ -412,7 +413,7 @@ void main() { ..url.path.equals('/api/v1/messages') ..url.queryParameters.deepEquals({ 'narrow': jsonEncode(narrow.apiEncode()), - 'anchor': AnchorCode.newest.toJson(), + 'anchor': AnchorCode.firstUnread.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), 'num_after': kMessageListFetchBatchSize.toString(), 'allow_empty_topic_name': 'true', @@ -445,7 +446,7 @@ void main() { ..url.path.equals('/api/v1/messages') ..url.queryParameters.deepEquals({ 'narrow': jsonEncode(narrow.apiEncode()), - 'anchor': AnchorCode.newest.toJson(), + 'anchor': AnchorCode.firstUnread.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), 'num_after': kMessageListFetchBatchSize.toString(), 'allow_empty_topic_name': 'true', diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index 46df165ecc..96fd62feeb 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -127,6 +127,8 @@ void main() { }, variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); }); + // TODO(#1571): test visitFirstUnread setting UI + // TODO maybe test GlobalSettingType.experimentalFeatureFlag settings // Or maybe not; after all, it's a developer-facing feature, so // should be low risk. From 2a5c741ee9a6719cf99ca62c87e1e6f34383ace5 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 11 Jun 2025 20:57:29 -0700 Subject: [PATCH 155/290] message test: Add some is-message-list-notified checks With comments on additional notifyListeners calls we expect when we start having the message list actually show the local echo in its various states. --- test/model/message_test.dart | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 0d28ed7dc0..e2e10133a3 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -169,32 +169,40 @@ void main() { await prepareOutboxMessage(destination: DmDestination( userIds: [eg.selfUser.userId, eg.otherUser.userId])); checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); // TODO once (it appears) await receiveMessage(eg.dmMessage(from: eg.selfUser, to: [eg.otherUser])); check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); })); test('smoke stream message: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { await prepareOutboxMessage(destination: StreamDestination( stream.streamId, eg.t('foo'))); checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); // TODO once (it appears) await receiveMessage(eg.streamMessage(stream: stream, topic: 'foo')); check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); })); test('hidden -> waiting and never transition to waitPeriodExpired', () => awaitFakeAsync((async) async { await prepareOutboxMessage(); checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); // TODO once (it appears) // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after // the send request was initiated. @@ -204,6 +212,7 @@ void main() { // The outbox message should stay in the waiting state; // it should not transition to waitPeriodExpired. checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); })); test('waiting -> waitPeriodExpired', () => awaitFakeAsync((async) async { @@ -211,9 +220,11 @@ void main() { kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); // TODO once (it appears) async.elapse(kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotNotified(); // TODO once (it offers restore) await check(outboxMessageFailFuture).throws(); })); @@ -231,10 +242,12 @@ void main() { destination: streamDestination, content: 'content'); async.elapse(kSendMessageOfferRestoreWaitPeriod); checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotNotified(); // TODO twice (it appears; it offers restore) // Wait till the [sendMessage] request succeeds. await future; checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); // TODO once (it un-offers restore) // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after // returning to the waiting state. @@ -243,15 +256,18 @@ void main() { // The outbox message should stay in the waiting state; // it should not transition to waitPeriodExpired. checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); })); group('… -> failed', () { test('hidden -> failed', () => awaitFakeAsync((async) async { await prepareOutboxMessageToFailAfterDelay(Duration.zero); checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); + checkNotNotified(); // TODO once (it appears, offering restore) // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after // the send request was initiated. @@ -260,6 +276,7 @@ void main() { // The outbox message should stay in the failed state; // it should not transition to waitPeriodExpired. checkState().equals(OutboxMessageState.failed); + checkNotNotified(); })); test('waiting -> failed', () => awaitFakeAsync((async) async { @@ -267,9 +284,11 @@ void main() { kLocalEchoDebounceDuration + Duration(seconds: 1)); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); // TODO once (it appears) await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); + checkNotNotified(); // TODO once (it offers restore) })); test('waitPeriodExpired -> failed', () => awaitFakeAsync((async) async { @@ -277,9 +296,11 @@ void main() { kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); async.elapse(kSendMessageOfferRestoreWaitPeriod); checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotNotified(); // TODO twice (it appears; it offers restore) await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); + checkNotNotified(); // TODO once (it shows failure text) })); }); @@ -287,9 +308,11 @@ void main() { test('hidden -> (delete) because event received', () => awaitFakeAsync((async) async { await prepareOutboxMessage(); checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); await receiveMessage(); check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); })); test('hidden -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { @@ -297,25 +320,30 @@ void main() { // the message event to arrive. await prepareOutboxMessageToFailAfterDelay(const Duration(seconds: 1)); checkState().equals(OutboxMessageState.hidden); + checkNotNotified(); // Received the message event while the message is being sent. await receiveMessage(); check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); // Complete the send request. There should be no error despite // the send request failure, because the outbox message is not // in the store any more. await check(outboxMessageFailFuture).completes(); async.elapse(const Duration(seconds: 1)); + checkNotNotified(); })); test('waiting -> (delete) because event received', () => awaitFakeAsync((async) async { await prepareOutboxMessage(); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); // TODO once (it appears) await receiveMessage(); check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); })); test('waiting -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { @@ -325,15 +353,18 @@ void main() { kLocalEchoDebounceDuration + Duration(seconds: 1)); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); + checkNotNotified(); // TODO once (it appears) // Received the message event while the message is being sent. await receiveMessage(); check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); // Complete the send request. There should be no error despite // the send request failure, because the outbox message is not // in the store any more. await check(outboxMessageFailFuture).completes(); + checkNotNotified(); })); test('waitPeriodExpired -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async { @@ -343,15 +374,18 @@ void main() { kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); async.elapse(kSendMessageOfferRestoreWaitPeriod); checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotNotified(); // TODO twice (it appears; it offers restore) // Received the message event while the message is being sent. await receiveMessage(); check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); // Complete the send request. There should be no error despite // the send request failure, because the outbox message is not // in the store any more. await check(outboxMessageFailFuture).completes(); + checkNotNotified(); })); test('waitPeriodExpired -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { @@ -361,27 +395,33 @@ void main() { kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); async.elapse(kSendMessageOfferRestoreWaitPeriod); checkState().equals(OutboxMessageState.waitPeriodExpired); + checkNotNotified(); // TODO twice (it appears; it offers restore) store.takeOutboxMessage(store.outboxMessages.keys.single); check(store.outboxMessages).isEmpty(); + checkNotNotified(); // TODO once (it disappears) })); test('failed -> (delete) because event received', () => awaitFakeAsync((async) async { await prepareOutboxMessageToFailAfterDelay(Duration.zero); await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); + checkNotNotified(); // TODO once (it appears, offering restore) await receiveMessage(); check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); })); test('failed -> (delete) because outbox message was taken', () => awaitFakeAsync((async) async { await prepareOutboxMessageToFailAfterDelay(Duration.zero); await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); + checkNotNotified(); // TODO once (it appears, offering restore) store.takeOutboxMessage(store.outboxMessages.keys.single); check(store.outboxMessages).isEmpty(); + checkNotNotified(); // TODO once (it disappears) })); }); @@ -423,11 +463,13 @@ void main() { await check(store.sendMessage( destination: StreamDestination(stream.streamId, eg.t('topic')), content: 'content')).throws(); + checkNotNotified(); // TODO once (it appears, offering restore) } final localMessageIds = store.outboxMessages.keys.toList(); store.takeOutboxMessage(localMessageIds.removeAt(5)); check(store.outboxMessages).keys.deepEquals(localMessageIds); + checkNotNotified(); // TODO once (it disappears) }); group('reconcileMessages', () { From 11f05dc3747f6383473fbafec20ebe575b9fc94e Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 27 Mar 2025 18:47:16 -0400 Subject: [PATCH 156/290] msglist: Add and manage outbox message objects in message list view This adds some overhead on message-event handling, linear in the number of outbox messages in a view. We rely on that number being small. We add outboxMessages as a list independent from messages on _MessageSequence. Because outbox messages are not rendered (the raw content is shown as plain text), we leave the 1-1 relationship between `messages` and `contents` unchanged. When computing `items`, we now start to look at `outboxMessages` as well, with the guarantee that the items related to outbox messages always come after those for other messages. Look for places that call `_processOutboxMessage(int index)` for references, and the changes to `checkInvariants` on how this affects the message list invariants. This implements minimal support to display outbox message message item widgets in the message list, without indicators for theirs states. Retrieving content from failed sent requests and the full UI are implemented in a later commit. Co-authored-by: Chris Bobbe --- lib/model/message.dart | 2 + lib/model/message_list.dart | 228 +++++++++- lib/widgets/message_list.dart | 32 ++ test/api/model/model_checks.dart | 4 + test/model/message_list_test.dart | 622 +++++++++++++++++++++++++++- test/model/message_test.dart | 44 +- test/widgets/message_list_test.dart | 37 ++ 7 files changed, 912 insertions(+), 57 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 719d0704f6..e8cfa6e6e1 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -394,6 +394,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes } } + // TODO predict outbox message moves using propagateMode + for (final view in _messageListViews) { view.messagesMoved(messageMove: messageMove, messageIds: event.messageIds); } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 458478f248..a7aff0dcbc 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -66,6 +66,22 @@ class MessageListMessageItem extends MessageListMessageBaseItem { }); } +/// An [OutboxMessage] to show in the message list. +class MessageListOutboxMessageItem extends MessageListMessageBaseItem { + @override + final OutboxMessage message; + @override + final ZulipContent content; + + MessageListOutboxMessageItem( + this.message, { + required super.showSender, + required super.isLastInBlock, + }) : content = ZulipContent(nodes: [ + ParagraphNode(links: null, nodes: [TextNode(message.contentMarkdown)]), + ]); +} + /// The status of outstanding or recent fetch requests from a [MessageListView]. enum FetchingStatus { /// The model has not made any fetch requests (since its last reset, if any). @@ -158,14 +174,24 @@ mixin _MessageSequence { /// It exists as an optimization, to memoize the work of parsing. final List contents = []; + /// The [OutboxMessage]s sent by the self-user, retrieved from + /// [MessageStore.outboxMessages]. + /// + /// See also [items]. + /// + /// O(N) iterations through this list are acceptable + /// because it won't normally have more than a few items. + final List outboxMessages = []; + /// The messages and their siblings in the UI, in order. /// /// This has a [MessageListMessageItem] corresponding to each element /// of [messages], in order. It may have additional items interspersed - /// before, between, or after the messages. + /// before, between, or after the messages. Then, similarly, + /// [MessageListOutboxMessageItem]s corresponding to [outboxMessages]. /// - /// This information is completely derived from [messages] and - /// the flags [haveOldest], [haveNewest], and [busyFetchingMore]. + /// This information is completely derived from [messages], [outboxMessages], + /// and the flags [haveOldest], [haveNewest], and [busyFetchingMore]. /// It exists as an optimization, to memoize that computation. /// /// See also [middleItem], an index which divides this list @@ -177,11 +203,14 @@ mixin _MessageSequence { /// The indices 0 to before [middleItem] are the top slice of [items], /// and the indices from [middleItem] to the end are the bottom slice. /// - /// The top and bottom slices of [items] correspond to - /// the top and bottom slices of [messages] respectively. - /// Either the bottom slices of both [items] and [messages] are empty, - /// or the first item in the bottom slice of [items] is a [MessageListMessageItem] - /// for the first message in the bottom slice of [messages]. + /// The top slice of [items] corresponds to the top slice of [messages]. + /// The bottom slice of [items] corresponds to the bottom slice of [messages] + /// plus any [outboxMessages]. + /// + /// The bottom slice will either be empty + /// or start with a [MessageListMessageBaseItem]. + /// It will not start with a [MessageListDateSeparatorItem] + /// or a [MessageListRecipientHeaderItem]. int middleItem = 0; int _findMessageWithId(int messageId) { @@ -197,9 +226,10 @@ mixin _MessageSequence { switch (item) { case MessageListRecipientHeaderItem(:var message): case MessageListDateSeparatorItem(:var message): - if (message.id == null) return 1; // TODO(#1441): test + if (message.id == null) return 1; return message.id! <= messageId ? -1 : 1; case MessageListMessageItem(:var message): return message.id.compareTo(messageId); + case MessageListOutboxMessageItem(): return 1; } } @@ -316,11 +346,48 @@ mixin _MessageSequence { _reprocessAll(); } + /// Append [outboxMessage] to [outboxMessages] and update derived data + /// accordingly. + /// + /// The caller is responsible for ensuring this is an appropriate thing to do + /// given [narrow] and other concerns. + void _addOutboxMessage(OutboxMessage outboxMessage) { + assert(haveNewest); + assert(!outboxMessages.contains(outboxMessage)); + outboxMessages.add(outboxMessage); + _processOutboxMessage(outboxMessages.length - 1); + } + + /// Remove the [outboxMessage] from the view. + /// + /// Returns true if the outbox message was removed, false otherwise. + bool _removeOutboxMessage(OutboxMessage outboxMessage) { + if (!outboxMessages.remove(outboxMessage)) { + return false; + } + _reprocessOutboxMessages(); + return true; + } + + /// Remove all outbox messages that satisfy [test] from [outboxMessages]. + /// + /// Returns true if any outbox messages were removed, false otherwise. + bool _removeOutboxMessagesWhere(bool Function(OutboxMessage) test) { + final count = outboxMessages.length; + outboxMessages.removeWhere(test); + if (outboxMessages.length == count) { + return false; + } + _reprocessOutboxMessages(); + return true; + } + /// Reset all [_MessageSequence] data, and cancel any active fetches. void _reset() { generation += 1; messages.clear(); middleMessage = 0; + outboxMessages.clear(); _haveOldest = false; _haveNewest = false; _status = FetchingStatus.unstarted; @@ -379,7 +446,6 @@ mixin _MessageSequence { assert(item.showSender == !canShareSender); assert(item.isLastInBlock); if (shouldSetMiddleItem) { - assert(item is MessageListMessageItem); middleItem = items.length; } items.add(item); @@ -390,6 +456,7 @@ mixin _MessageSequence { /// The previous messages in the list must already have been processed. /// This message must already have been parsed and reflected in [contents]. void _processMessage(int index) { + assert(items.lastOrNull is! MessageListOutboxMessageItem); final prevMessage = index == 0 ? null : messages[index - 1]; final message = messages[index]; final content = contents[index]; @@ -401,13 +468,67 @@ mixin _MessageSequence { message, content, showSender: !canShareSender, isLastInBlock: true)); } - /// Recompute [items] from scratch, based on [messages], [contents], and flags. + /// Append to [items] based on the index-th message in [outboxMessages]. + /// + /// All [messages] and previous messages in [outboxMessages] must already have + /// been processed. + void _processOutboxMessage(int index) { + final prevMessage = index == 0 ? messages.lastOrNull + : outboxMessages[index - 1]; + final message = outboxMessages[index]; + + _addItemsForMessage(message, + // The first outbox message item becomes the middle item + // when the bottom slice of [messages] is empty. + shouldSetMiddleItem: index == 0 && middleMessage == messages.length, + prevMessage: prevMessage, + buildItem: (bool canShareSender) => MessageListOutboxMessageItem( + message, showSender: !canShareSender, isLastInBlock: true)); + } + + /// Remove items associated with [outboxMessages] from [items]. + /// + /// This is designed to be idempotent; repeated calls will not change the + /// content of [items]. + /// + /// This is efficient due to the expected small size of [outboxMessages]. + void _removeOutboxMessageItems() { + // This loop relies on the assumption that all items that follow + // the last [MessageListMessageItem] are derived from outbox messages. + while (items.isNotEmpty && items.last is! MessageListMessageItem) { + items.removeLast(); + } + + if (items.isNotEmpty) { + final lastItem = items.last as MessageListMessageItem; + lastItem.isLastInBlock = true; + } + if (middleMessage == messages.length) middleItem = items.length; + } + + /// Recompute the portion of [items] derived from outbox messages, + /// based on [outboxMessages] and [messages]. + /// + /// All [messages] should have been processed when this is called. + void _reprocessOutboxMessages() { + assert(haveNewest); + _removeOutboxMessageItems(); + for (var i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } + } + + /// Recompute [items] from scratch, based on [messages], [contents], + /// [outboxMessages] and flags. void _reprocessAll() { items.clear(); for (var i = 0; i < messages.length; i++) { _processMessage(i); } if (middleMessage == messages.length) middleItem = items.length; + for (var i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } } } @@ -602,6 +723,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { } _haveOldest = result.foundOldest; _haveNewest = result.foundNewest; + + if (haveNewest) { + _syncOutboxMessagesFromStore(); + } + _setStatus(FetchingStatus.idle, was: FetchingStatus.fetchInitial); } @@ -706,6 +832,10 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } _haveNewest = result.foundNewest; + + if (haveNewest) { + _syncOutboxMessagesFromStore(); + } }); } @@ -770,9 +900,42 @@ class MessageListView with ChangeNotifier, _MessageSequence { fetchInitial(); } + bool _shouldAddOutboxMessage(OutboxMessage outboxMessage) { + assert(haveNewest); + return !outboxMessage.hidden + && narrow.containsMessage(outboxMessage) + && _messageVisible(outboxMessage); + } + + /// Reads [MessageStore.outboxMessages] and copies to [outboxMessages] + /// the ones belonging to this view. + /// + /// This should only be called when [haveNewest] is true + /// because outbox messages are considered newer than regular messages. + /// + /// This does not call [notifyListeners]. + void _syncOutboxMessagesFromStore() { + assert(haveNewest); + assert(outboxMessages.isEmpty); + for (final outboxMessage in store.outboxMessages.values) { + if (_shouldAddOutboxMessage(outboxMessage)) { + _addOutboxMessage(outboxMessage); + } + } + } + /// Add [outboxMessage] if it belongs to the view. void addOutboxMessage(OutboxMessage outboxMessage) { - // TODO(#1441) implement this + // We don't have the newest messages; + // we shouldn't show any outbox messages until we do. + if (!haveNewest) return; + + assert(outboxMessages.none( + (message) => message.localMessageId == outboxMessage.localMessageId)); + if (_shouldAddOutboxMessage(outboxMessage)) { + _addOutboxMessage(outboxMessage); + notifyListeners(); + } } /// Remove the [outboxMessage] from the view. @@ -781,7 +944,9 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// /// This should only be called from [MessageStore.takeOutboxMessage]. void removeOutboxMessage(OutboxMessage outboxMessage) { - // TODO(#1441) implement this + if (_removeOutboxMessage(outboxMessage)) { + notifyListeners(); + } } void handleUserTopicEvent(UserTopicEvent event) { @@ -790,10 +955,17 @@ class MessageListView with ChangeNotifier, _MessageSequence { return; case VisibilityEffect.muted: - if (_removeMessagesWhere((message) => - (message is StreamMessage - && message.streamId == event.streamId - && message.topic == event.topicName))) { + bool removed = _removeMessagesWhere((message) => + message is StreamMessage + && message.streamId == event.streamId + && message.topic == event.topicName); + + removed |= _removeOutboxMessagesWhere((message) => + message is StreamOutboxMessage + && message.conversation.streamId == event.streamId + && message.conversation.topic == event.topicName); + + if (removed) { notifyListeners(); } @@ -819,6 +991,8 @@ class MessageListView with ChangeNotifier, _MessageSequence { void handleMessageEvent(MessageEvent event) { final message = event.message; if (!narrow.containsMessage(message) || !_messageVisible(message)) { + assert(event.localMessageId == null || outboxMessages.none((message) => + message.localMessageId == int.parse(event.localMessageId!, radix: 10))); return; } if (!haveNewest) { @@ -833,8 +1007,20 @@ class MessageListView with ChangeNotifier, _MessageSequence { // didn't include this message. return; } - // TODO insert in middle instead, when appropriate + + // Remove the outbox messages temporarily. + // We'll add them back after the new message. + _removeOutboxMessageItems(); + // TODO insert in middle of [messages] instead, when appropriate _addMessage(message); + if (event.localMessageId != null) { + final localMessageId = int.parse(event.localMessageId!, radix: 10); + // [outboxMessages] is expected to be short, so removing the corresponding + // outbox message and reprocessing them all in linear time is efficient. + outboxMessages.removeWhere( + (message) => message.localMessageId == localMessageId); + } + _reprocessOutboxMessages(); notifyListeners(); } @@ -955,7 +1141,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// Notify listeners if the given outbox message is present in this view. void notifyListenersIfOutboxMessagePresent(int localMessageId) { - // TODO(#1441) implement this + final isAnyPresent = + outboxMessages.any((message) => message.localMessageId == localMessageId); + if (isAnyPresent) { + notifyListeners(); + } } /// Called when the app is reassembled during debugging, e.g. for hot reload. diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 513043fea6..b49e64a474 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -814,6 +814,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat key: ValueKey(data.message.id), header: header, item: data); + case MessageListOutboxMessageItem(): + final header = RecipientHeader(message: data.message, narrow: widget.narrow); + return MessageItem(header: header, item: data); } } } @@ -1152,6 +1155,7 @@ class MessageItem extends StatelessWidget { child: Column(children: [ switch (item) { MessageListMessageItem() => MessageWithPossibleSender(item: item), + MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item), }, // TODO refine this padding; discussion: // https://github.com/zulip/zulip-flutter/pull/1453#discussion_r2106526985 @@ -1732,3 +1736,31 @@ class _RestoreEditMessageGestureDetector extends StatelessWidget { child: child); } } + +/// A "local echo" placeholder for a Zulip message to be sent by the self-user. +/// +/// See also [OutboxMessage]. +class OutboxMessageWithPossibleSender extends StatelessWidget { + const OutboxMessageWithPossibleSender({super.key, required this.item}); + + final MessageListOutboxMessageItem item; + + @override + Widget build(BuildContext context) { + final message = item.message; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column(children: [ + if (item.showSender) + _SenderRow(message: message, showTimestamp: false), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + // This is adapted from [MessageContent]. + // TODO(#576): Offer InheritedMessage ancestor once we are ready + // to support local echoing images and lightbox. + child: DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: item.content.nodes))), + ])); + } +} diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 17bd86ee9e..262395d955 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -42,6 +42,10 @@ extension StreamConversationChecks on Subject { Subject get displayRecipient => has((x) => x.displayRecipient, 'displayRecipient'); } +extension DmConversationChecks on Subject { + Subject> get allRecipientIds => has((x) => x.allRecipientIds, 'allRecipientIds'); +} + extension MessageBaseChecks on Subject> { Subject get id => has((e) => e.id, 'id'); Subject get senderId => has((e) => e.senderId, 'senderId'); diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 0eb30c1cbb..a19229e4a2 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1,8 +1,11 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter/foundation.dart'; +import 'package:clock/clock.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/backoff.dart'; @@ -10,8 +13,10 @@ import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.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/algorithms.dart'; import 'package:zulip/model/content.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -21,7 +26,9 @@ import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; import '../stdlib_checks.dart'; +import 'binding.dart'; import 'content_checks.dart'; +import 'message_checks.dart'; import 'recent_senders_test.dart' as recent_senders_test; import 'test_store.dart'; @@ -49,6 +56,8 @@ void main() { FlutterError.dumpErrorToConsole(details, forceReport: true); }; + TestZulipBinding.ensureInitialized(); + // These variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; @@ -71,8 +80,9 @@ void main() { Future prepare({ Narrow narrow = const CombinedFeedNarrow(), Anchor anchor = AnchorCode.newest, + ZulipStream? stream, }) async { - final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); store = eg.store(); await store.addStream(stream); @@ -108,6 +118,26 @@ void main() { checkNotifiedOnce(); } + Future prepareOutboxMessages({ + required int count, + required ZulipStream stream, + String topic = 'some topic', + }) async { + for (int i = 0; i < count; i++) { + connection.prepare(json: SendMessageResult(id: 123).toJson()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t(topic)), + content: 'content'); + } + } + + Future prepareOutboxMessagesTo(List destinations) async { + for (final destination in destinations) { + connection.prepare(json: SendMessageResult(id: 123).toJson()); + await store.sendMessage(destination: destination, content: 'content'); + } + } + void checkLastRequest({ required ApiNarrow narrow, required String anchor, @@ -246,6 +276,105 @@ void main() { test('numeric', () => checkFetchWithAnchor(NumericAnchor(12345))); }); + test('no messages found in fetch; outbox messages present', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + + connection.prepare( + json: newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..outboxMessages.length.equals(1); + })); + + test('some messages found in fetch; outbox messages present', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + + connection.prepare(json: newestResult(foundOldest: true, + messages: [eg.streamMessage(stream: stream, topic: 'topic')]).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..outboxMessages.length.equals(1); + })); + + test('outbox messages not added until haveNewest', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), + anchor: AnchorCode.firstUnread, + stream: stream); + + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model)..fetched.isFalse()..outboxMessages.isEmpty(); + + final message = eg.streamMessage(stream: stream, topic: 'topic'); + connection.prepare(json: nearResult( + anchor: message.id, + foundOldest: true, + foundNewest: false, + messages: [message]).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model)..fetched.isTrue()..haveNewest.isFalse()..outboxMessages.isEmpty(); + + connection.prepare(json: newerResult(anchor: message.id, foundNewest: true, + messages: [eg.streamMessage(stream: stream, topic: 'topic')]).toJson()); + final fetchFuture = model.fetchNewer(); + checkNotifiedOnce(); + await fetchFuture; + checkNotifiedOnce(); + check(model)..haveNewest.isTrue()..outboxMessages.length.equals(1); + })); + + test('ignore [OutboxMessage]s outside narrow or with `hidden: true`', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final otherStream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t('topic')), + StreamDestination(stream.streamId, eg.t('muted')), + StreamDestination(otherStream.streamId, eg.t('topic')), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + + await prepareOutboxMessagesTo( + [StreamDestination(stream.streamId, eg.t('topic'))]); + assert(store.outboxMessages.values.last.hidden); + + connection.prepare(json: + newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model).outboxMessages.single.isA().conversation + ..streamId.equals(stream.streamId) + ..topic.equals(eg.t('topic')); + })); + // TODO(#824): move this test test('recent senders track all the messages', () async { const narrow = CombinedFeedNarrow(); @@ -614,6 +743,199 @@ void main() { checkNotNotified(); check(model).fetched.isFalse(); }); + + test('when there are outbox messages', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model) + ..messages.length.equals(30) + ..outboxMessages.length.equals(5); + + await store.handleEvent(eg.messageEvent(eg.streamMessage(stream: stream))); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.length.equals(5); + })); + + test('from another client (localMessageId present but unrecognized)', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic')); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: 1234)); + check(store.outboxMessages).isEmpty(); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + })); + + test('for an OutboxMessage in the narrow', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + final localMessageId = store.outboxMessages.keys.first; + check(model) + ..messages.length.equals(30) + ..outboxMessages.length.equals(5) + ..outboxMessages.any((message) => + message.localMessageId.equals(localMessageId)); + + await store.handleEvent(eg.messageEvent(eg.streamMessage(stream: stream), + localMessageId: localMessageId)); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.length.equals(4) + ..outboxMessages.every((message) => + message.localMessageId.not((m) => m.equals(localMessageId))); + })); + + test('for an OutboxMessage outside the narrow', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic')); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + + await prepareOutboxMessages(count: 5, stream: stream, topic: 'other'); + final localMessageId = store.outboxMessages.keys.first; + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'other'), + localMessageId: localMessageId)); + checkNotNotified(); + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + })); + }); + + group('addOutboxMessage', () { + final stream = eg.stream(); + + test('in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + await prepareOutboxMessages(count: 5, stream: stream); + check(model).outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model).outboxMessages.length.equals(5); + })); + + test('not in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareOutboxMessages(count: 5, stream: stream, topic: 'other topic'); + check(model).outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model).outboxMessages.isEmpty(); + })); + + test('before fetch', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareOutboxMessages(count: 5, stream: stream); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + })); + }); + + group('removeOutboxMessage', () { + final stream = eg.stream(); + + Future prepareFailedOutboxMessages(FakeAsync async, { + required int count, + required ZulipStream stream, + String topic = 'some topic', + }) async { + for (int i = 0; i < count; i++) { + connection.prepare(httpException: SocketException('failed')); + await check(store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t(topic)), + content: 'content')).throws(); + } + } + + test('in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareFailedOutboxMessages(async, + count: 5, stream: stream); + check(model).outboxMessages.length.equals(5); + checkNotified(count: 5); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + checkNotifiedOnce(); + check(model).outboxMessages.length.equals(4); + })); + + test('not in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareFailedOutboxMessages(async, + count: 5, stream: stream, topic: 'other topic'); + check(model).outboxMessages.isEmpty(); + checkNotNotified(); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + check(model).outboxMessages.isEmpty(); + checkNotNotified(); + })); + + test('removed outbox message is the only message in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareFailedOutboxMessages(async, + count: 1, stream: stream); + check(model).outboxMessages.single; + checkNotified(count: 1); + + store.takeOutboxMessage(store.outboxMessages.keys.first); + check(model).outboxMessages.isEmpty(); + checkNotifiedOnce(); + })); }); group('UserTopicEvent', () { @@ -637,7 +959,7 @@ void main() { await setVisibility(policy); } - test('mute a visible topic', () async { + test('mute a visible topic', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); await prepareMutes(); final otherStream = eg.stream(); @@ -651,10 +973,49 @@ void main() { ]); checkHasMessageIds([1, 2, 3, 4]); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t('elsewhere')), + DmDestination(userIds: [eg.selfUser.userId]), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 3); + check(model).outboxMessages.deepEquals(>[ + (it) => it.isA() + .conversation.topic.equals(eg.t(topic)), + (it) => it.isA() + .conversation.topic.equals(eg.t('elsewhere')), + (it) => it.isA() + .conversation.allRecipientIds.deepEquals([eg.selfUser.userId]), + ]); + await setVisibility(UserTopicVisibilityPolicy.muted); checkNotifiedOnce(); checkHasMessageIds([1, 3, 4]); - }); + check(model).outboxMessages.deepEquals(>[ + (it) => it.isA() + .conversation.topic.equals(eg.t('elsewhere')), + (it) => it.isA() + .conversation.allRecipientIds.deepEquals([eg.selfUser.userId]), + ]); + })); + + test('mute a visible topic containing only outbox messages', () => awaitFakeAsync((async) async { + await prepare(narrow: const CombinedFeedNarrow()); + await prepareMutes(); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t(topic)), + ]); + async.elapse(kLocalEchoDebounceDuration); + check(model).outboxMessages.length.equals(2); + checkNotified(count: 2); + + await setVisibility(UserTopicVisibilityPolicy.muted); + check(model).outboxMessages.isEmpty(); + checkNotifiedOnce(); + })); test('in CombinedFeedNarrow, use combined-feed visibility', () async { // Compare the parallel ChannelNarrow test below. @@ -729,7 +1090,7 @@ void main() { checkHasMessageIds([1]); }); - test('no affected messages -> no notification', () async { + test('no affected messages -> no notification', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); await prepareMutes(); await prepareMessages(foundOldest: true, messages: [ @@ -737,10 +1098,17 @@ void main() { ]); checkHasMessageIds([1]); + await prepareOutboxMessagesTo( + [StreamDestination(stream.streamId, eg.t('bar'))]); + async.elapse(kLocalEchoDebounceDuration); + final outboxMessage = model.outboxMessages.single; + checkNotifiedOnce(); + await setVisibility(UserTopicVisibilityPolicy.muted); checkNotNotified(); checkHasMessageIds([1]); - }); + check(model).outboxMessages.single.equals(outboxMessage); + })); test('unmute a topic -> refetch from scratch', () => awaitFakeAsync((async) async { await prepare(narrow: const CombinedFeedNarrow()); @@ -750,7 +1118,14 @@ void main() { eg.streamMessage(id: 2, stream: stream, topic: topic), ]; await prepareMessages(foundOldest: true, messages: messages); + await store.addUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t(topic)), + StreamDestination(stream.streamId, eg.t('muted')), + ]); + async.elapse(kLocalEchoDebounceDuration); checkHasMessageIds([1]); + check(model).outboxMessages.isEmpty(); connection.prepare( json: newestResult(foundOldest: true, messages: messages).toJson()); @@ -758,10 +1133,14 @@ void main() { checkNotifiedOnce(); check(model).fetched.isFalse(); checkHasMessageIds([]); + check(model).outboxMessages.isEmpty(); async.elapse(Duration.zero); checkNotifiedOnce(); checkHasMessageIds([1, 2]); + check(model).outboxMessages.single.isA().conversation + ..streamId.equals(stream.streamId) + ..topic.equals(eg.t(topic)); })); test('unmute a topic before initial fetch completes -> do nothing', () => awaitFakeAsync((async) async { @@ -907,6 +1286,38 @@ void main() { }); }); + group('notifyListenersIfOutboxMessagePresent', () { + final stream = eg.stream(); + + test('message present', () => awaitFakeAsync((async) async { + await prepare(narrow: const CombinedFeedNarrow(), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 5, stream: stream); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + + model.notifyListenersIfOutboxMessagePresent( + store.outboxMessages.keys.first); + checkNotifiedOnce(); + })); + + test('message not present', () => awaitFakeAsync((async) async { + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'some topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 5, + stream: stream, topic: 'other topic'); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + + model.notifyListenersIfOutboxMessagePresent( + store.outboxMessages.keys.first); + checkNotNotified(); + })); + }); + group('messageContentChanged', () { test('message present', () async { await prepare(narrow: const CombinedFeedNarrow()); @@ -1036,6 +1447,26 @@ void main() { checkNotifiedOnce(); }); + test('channel -> new channel (with outbox messages): remove moved messages; outbox messages unaffected', () => awaitFakeAsync((async) async { + final narrow = ChannelNarrow(stream.streamId); + await prepareNarrow(narrow, initialMessages + movedMessages); + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await prepareOutboxMessages(count: 5, stream: stream); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + final outboxMessagesCopy = model.outboxMessages.toList(); + + await store.handleEvent(eg.updateMessageEventMoveFrom( + origMessages: movedMessages, + newTopicStr: 'new', + newStreamId: otherStream.streamId, + )); + checkHasMessages(initialMessages); + check(model).outboxMessages.deepEquals(outboxMessagesCopy); + checkNotifiedOnce(); + })); + test('unrelated channel -> new channel: unaffected', () async { final thirdStream = eg.stream(); await prepareNarrow(narrow, initialMessages); @@ -1737,6 +2168,39 @@ void main() { checkHasMessageIds(expected); }); + test('handle outbox messages', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + await store.addUserTopic(stream, 'muted', UserTopicVisibilityPolicy.muted); + await prepareMessages(foundOldest: true, messages: []); + + // Check filtering on sent messages… + await prepareOutboxMessagesTo([ + StreamDestination(stream.streamId, eg.t('not muted')), + StreamDestination(stream.streamId, eg.t('muted')), + ]); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + check(model.outboxMessages).single.isA() + .conversation.topic.equals(eg.t('not muted')); + + final messages = [eg.streamMessage(stream: stream)]; + connection.prepare(json: newestResult( + foundOldest: true, messages: messages).toJson()); + // Check filtering on fetchInitial… + await store.handleEvent(eg.updateMessageEventMoveTo( + newMessages: messages, + origStreamId: eg.stream().streamId)); + checkNotifiedOnce(); + check(model).fetched.isFalse(); + async.elapse(Duration.zero); + check(model).fetched.isTrue(); + check(model.outboxMessages).single.isA() + .conversation.topic.equals(eg.t('not muted')); + })); + test('in TopicNarrow', () async { final stream = eg.stream(); await prepare(narrow: eg.topicNarrow(stream.streamId, 'A')); @@ -2115,7 +2579,55 @@ void main() { }); }); - test('recipient headers are maintained consistently', () async { + group('findItemWithMessageId', () { + test('has MessageListDateSeparatorItem with null message ID', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, topic: 'topic', + timestamp: eg.utcTimestamp(clock.daysAgo(1))); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: [message]); + + // `findItemWithMessageId` uses binary search. Set up just enough + // outbox message items, so that a [MessageListDateSeparatorItem] for + // the outbox messages is right in the middle. + await prepareOutboxMessages(count: 2, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 2); + check(model.items).deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA().message.id.isNull(), + (it) => it.isA(), + (it) => it.isA(), + ]); + check(model.findItemWithMessageId(message.id)).equals(1); + })); + + test('has MessageListOutboxMessageItem', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, topic: 'topic', + timestamp: eg.utcTimestamp(clock.now())); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: [message]); + + // `findItemWithMessageId` uses binary search. Set up just enough + // outbox message items, so that a [MessageListOutboxMessageItem] + // is right in the middle. + await prepareOutboxMessages(count: 3, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 3); + check(model.items).deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + (it) => it.isA(), + ]); + check(model.findItemWithMessageId(message.id)).equals(1); + })); + }); + + test('recipient headers are maintained consistently', () => awaitFakeAsync((async) async { // TODO test date separators are maintained consistently too // This tests the code that maintains the invariant that recipient headers // are present just where they're required. @@ -2128,7 +2640,7 @@ void main() { // just needs messages that have the same recipient, and that don't, and // doesn't need to exercise the different reasons that messages don't. - const timestamp = 1693602618; + final timestamp = eg.utcTimestamp(clock.now()); final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); Message streamMessage(int id) => eg.streamMessage(id: id, stream: stream, topic: 'foo', timestamp: timestamp); @@ -2187,6 +2699,20 @@ void main() { model.reassemble(); checkNotifiedOnce(); + // Then test outbox message, where a new header is needed… + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: DmDestination(userIds: [eg.selfUser.userId]), content: 'hi'); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + + // … and where it's not. + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: DmDestination(userIds: [eg.selfUser.userId]), content: 'hi'); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + // Have a new fetchOlder reach the oldest, so that a history-start marker appears… connection.prepare(json: olderResult( anchor: model.messages[0].id, @@ -2199,17 +2725,33 @@ void main() { // … and then test reassemble again. model.reassemble(); checkNotifiedOnce(); - }); - test('showSender is maintained correctly', () async { + final outboxMessageIds = store.outboxMessages.keys.toList(); + // Then test removing the first outbox message… + await store.handleEvent(eg.messageEvent( + dmMessage(15), localMessageId: outboxMessageIds.first)); + checkNotifiedOnce(); + + // … and handling a new non-outbox message… + await store.handleEvent(eg.messageEvent(streamMessage(16))); + checkNotifiedOnce(); + + // … and removing the second outbox message. + await store.handleEvent(eg.messageEvent( + dmMessage(17), localMessageId: outboxMessageIds.last)); + checkNotifiedOnce(); + })); + + test('showSender is maintained correctly', () => awaitFakeAsync((async) async { // TODO(#150): This will get more complicated with message moves. // Until then, we always compute this sequentially from oldest to newest. // So we just need to exercise the different cases of the logic for // whether the sender should be shown, but the difference between // fetchInitial and handleMessageEvent etc. doesn't matter. - const t1 = 1693602618; - const t2 = t1 + 86400; + final now = clock.now(); + final t1 = eg.utcTimestamp(now.subtract(Duration(days: 1))); + final t2 = eg.utcTimestamp(now); final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); Message streamMessage(int id, int timestamp, User sender) => eg.streamMessage(id: id, sender: sender, @@ -2217,6 +2759,8 @@ void main() { Message dmMessage(int id, int timestamp, User sender) => eg.dmMessage(id: id, from: sender, timestamp: timestamp, to: [sender.userId == eg.selfUser.userId ? eg.otherUser : eg.selfUser]); + DmDestination dmDestination(List users) => + DmDestination(userIds: users.map((user) => user.userId).toList()); await prepare(); await prepareMessages(foundOldest: true, messages: [ @@ -2226,6 +2770,13 @@ void main() { dmMessage(4, t1, eg.otherUser), // same sender, but new recipient dmMessage(5, t2, eg.otherUser), // same sender/recipient, but new day ]); + await prepareOutboxMessagesTo([ + dmDestination([eg.selfUser, eg.otherUser]), // same day, but new sender + dmDestination([eg.selfUser, eg.otherUser]), // hide sender + ]); + assert( + store.outboxMessages.values.every((message) => message.timestamp == t2)); + async.elapse(kLocalEchoDebounceDuration); // We check showSender has the right values in [checkInvariants], // but to make this test explicit: @@ -2238,8 +2789,10 @@ void main() { (it) => it.isA().showSender.isTrue(), (it) => it.isA(), (it) => it.isA().showSender.isTrue(), + (it) => it.isA().showSender.isTrue(), + (it) => it.isA().showSender.isFalse(), ]); - }); + })); group('haveSameRecipient', () { test('stream messages vs DMs, no match', () { @@ -2310,6 +2863,16 @@ void main() { doTest('same letters, different diacritics', 'ma', 'mǎ', false); doTest('having different CJK characters', '嗎', '馬', false); }); + + test('outbox messages', () { + final stream = eg.stream(); + final streamMessage1 = eg.streamOutboxMessage(stream: stream, topic: 'foo'); + final streamMessage2 = eg.streamOutboxMessage(stream: stream, topic: 'bar'); + final dmMessage = eg.dmOutboxMessage(from: eg.selfUser, to: [eg.otherUser]); + check(haveSameRecipient(streamMessage1, streamMessage1)).isTrue(); + check(haveSameRecipient(streamMessage1, streamMessage2)).isFalse(); + check(haveSameRecipient(streamMessage1, dmMessage)).isFalse(); + }); }); test('messagesSameDay', () { @@ -2345,6 +2908,14 @@ void main() { eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)), eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)), )).equals(i0 == i1); + check(because: 'times $time0, $time1', messagesSameDay( + eg.streamOutboxMessage(timestamp: timestampFromLocalTime(time0)), + eg.streamOutboxMessage(timestamp: timestampFromLocalTime(time1)), + )).equals(i0 == i1); + check(because: 'times $time0, $time1', messagesSameDay( + eg.dmOutboxMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)), + eg.dmOutboxMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)), + )).equals(i0 == i1); } } } @@ -2360,6 +2931,7 @@ void checkInvariants(MessageListView model) { if (!model.fetched) { check(model) ..messages.isEmpty() + ..outboxMessages.isEmpty() ..haveOldest.isFalse() ..haveNewest.isFalse() ..busyFetchingMore.isFalse(); @@ -2371,8 +2943,15 @@ void checkInvariants(MessageListView model) { for (final message in model.messages) { check(model.store.messages)[message.id].isNotNull().identicalTo(message); } + if (model.outboxMessages.isNotEmpty) { + check(model.haveNewest).isTrue(); + } + for (final message in model.outboxMessages) { + check(message).hidden.isFalse(); + check(model.store.outboxMessages)[message.localMessageId].isNotNull().identicalTo(message); + } - final allMessages = >[...model.messages]; + final allMessages = [...model.messages, ...model.outboxMessages]; for (final message in allMessages) { check(model.narrow.containsMessage(message)).isTrue(); @@ -2395,6 +2974,8 @@ void checkInvariants(MessageListView model) { check(isSortedWithoutDuplicates(model.messages.map((m) => m.id).toList())) .isTrue(); + check(isSortedWithoutDuplicates(model.outboxMessages.map((m) => m.localMessageId).toList())) + .isTrue(); check(model).middleMessage ..isGreaterOrEqual(0) @@ -2444,7 +3025,8 @@ void checkInvariants(MessageListView model) { ..message.identicalTo(model.messages[j]) ..content.identicalTo(model.contents[j]); } else { - assert(false); + check(model.items[i]).isA() + .message.identicalTo(model.outboxMessages[j-model.messages.length]); } check(model.items[i++]).isA() ..showSender.equals( @@ -2452,6 +3034,7 @@ void checkInvariants(MessageListView model) { ..isLastInBlock.equals( i == model.items.length || switch (model.items[i]) { MessageListMessageItem() + || MessageListOutboxMessageItem() || MessageListDateSeparatorItem() => false, MessageListRecipientHeaderItem() => true, }); @@ -2461,8 +3044,14 @@ void checkInvariants(MessageListView model) { check(model).middleItem ..isGreaterOrEqual(0) ..isLessOrEqual(model.items.length); - if (model.middleItem == model.items.length) { - check(model.middleMessage).equals(model.messages.length); + if (model.middleMessage == model.messages.length) { + if (model.outboxMessages.isEmpty) { + // the bottom slice of `model.messages` is empty + check(model).middleItem.equals(model.items.length); + } else { + check(model.items[model.middleItem]).isA() + .message.identicalTo(model.outboxMessages.first); + } } else { check(model.items[model.middleItem]).isA() .message.identicalTo(model.messages[model.middleMessage]); @@ -2503,6 +3092,7 @@ extension MessageListViewChecks on Subject { Subject get store => has((x) => x.store, 'store'); Subject get narrow => has((x) => x.narrow, 'narrow'); Subject> get messages => has((x) => x.messages, 'messages'); + Subject> get outboxMessages => has((x) => x.outboxMessages, 'outboxMessages'); Subject get middleMessage => has((x) => x.middleMessage, 'middleMessage'); Subject> get contents => has((x) => x.contents, 'contents'); Subject> get items => has((x) => x.items, 'items'); diff --git a/test/model/message_test.dart b/test/model/message_test.dart index e2e10133a3..762cc41452 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -173,7 +173,7 @@ void main() { async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); - checkNotNotified(); // TODO once (it appears) + checkNotifiedOnce(); await receiveMessage(eg.dmMessage(from: eg.selfUser, to: [eg.otherUser])); check(store.outboxMessages).isEmpty(); @@ -188,7 +188,7 @@ void main() { async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); - checkNotNotified(); // TODO once (it appears) + checkNotifiedOnce(); await receiveMessage(eg.streamMessage(stream: stream, topic: 'foo')); check(store.outboxMessages).isEmpty(); @@ -202,7 +202,7 @@ void main() { async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); - checkNotNotified(); // TODO once (it appears) + checkNotifiedOnce(); // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after // the send request was initiated. @@ -220,11 +220,11 @@ void main() { kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); - checkNotNotified(); // TODO once (it appears) + checkNotifiedOnce(); async.elapse(kSendMessageOfferRestoreWaitPeriod - kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waitPeriodExpired); - checkNotNotified(); // TODO once (it offers restore) + checkNotifiedOnce(); await check(outboxMessageFailFuture).throws(); })); @@ -242,12 +242,12 @@ void main() { destination: streamDestination, content: 'content'); async.elapse(kSendMessageOfferRestoreWaitPeriod); checkState().equals(OutboxMessageState.waitPeriodExpired); - checkNotNotified(); // TODO twice (it appears; it offers restore) + checkNotified(count: 2); // Wait till the [sendMessage] request succeeds. await future; checkState().equals(OutboxMessageState.waiting); - checkNotNotified(); // TODO once (it un-offers restore) + checkNotifiedOnce(); // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after // returning to the waiting state. @@ -267,7 +267,7 @@ void main() { await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); - checkNotNotified(); // TODO once (it appears, offering restore) + checkNotifiedOnce(); // Wait till we reach at least [kSendMessageOfferRestoreWaitPeriod] after // the send request was initiated. @@ -284,11 +284,11 @@ void main() { kLocalEchoDebounceDuration + Duration(seconds: 1)); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); - checkNotNotified(); // TODO once (it appears) + checkNotifiedOnce(); await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); - checkNotNotified(); // TODO once (it offers restore) + checkNotifiedOnce(); })); test('waitPeriodExpired -> failed', () => awaitFakeAsync((async) async { @@ -296,11 +296,11 @@ void main() { kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); async.elapse(kSendMessageOfferRestoreWaitPeriod); checkState().equals(OutboxMessageState.waitPeriodExpired); - checkNotNotified(); // TODO twice (it appears; it offers restore) + checkNotified(count: 2); await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); - checkNotNotified(); // TODO once (it shows failure text) + checkNotifiedOnce(); })); }); @@ -339,7 +339,7 @@ void main() { await prepareOutboxMessage(); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); - checkNotNotified(); // TODO once (it appears) + checkNotifiedOnce(); await receiveMessage(); check(store.outboxMessages).isEmpty(); @@ -353,7 +353,7 @@ void main() { kLocalEchoDebounceDuration + Duration(seconds: 1)); async.elapse(kLocalEchoDebounceDuration); checkState().equals(OutboxMessageState.waiting); - checkNotNotified(); // TODO once (it appears) + checkNotifiedOnce(); // Received the message event while the message is being sent. await receiveMessage(); @@ -374,7 +374,7 @@ void main() { kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); async.elapse(kSendMessageOfferRestoreWaitPeriod); checkState().equals(OutboxMessageState.waitPeriodExpired); - checkNotNotified(); // TODO twice (it appears; it offers restore) + checkNotified(count: 2); // Received the message event while the message is being sent. await receiveMessage(); @@ -395,18 +395,18 @@ void main() { kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); async.elapse(kSendMessageOfferRestoreWaitPeriod); checkState().equals(OutboxMessageState.waitPeriodExpired); - checkNotNotified(); // TODO twice (it appears; it offers restore) + checkNotified(count: 2); store.takeOutboxMessage(store.outboxMessages.keys.single); check(store.outboxMessages).isEmpty(); - checkNotNotified(); // TODO once (it disappears) + checkNotifiedOnce(); })); test('failed -> (delete) because event received', () => awaitFakeAsync((async) async { await prepareOutboxMessageToFailAfterDelay(Duration.zero); await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); - checkNotNotified(); // TODO once (it appears, offering restore) + checkNotifiedOnce(); await receiveMessage(); check(store.outboxMessages).isEmpty(); @@ -417,11 +417,11 @@ void main() { await prepareOutboxMessageToFailAfterDelay(Duration.zero); await check(outboxMessageFailFuture).throws(); checkState().equals(OutboxMessageState.failed); - checkNotNotified(); // TODO once (it appears, offering restore) + checkNotifiedOnce(); store.takeOutboxMessage(store.outboxMessages.keys.single); check(store.outboxMessages).isEmpty(); - checkNotNotified(); // TODO once (it disappears) + checkNotifiedOnce(); })); }); @@ -463,13 +463,13 @@ void main() { await check(store.sendMessage( destination: StreamDestination(stream.streamId, eg.t('topic')), content: 'content')).throws(); - checkNotNotified(); // TODO once (it appears, offering restore) + checkNotifiedOnce(); } final localMessageIds = store.outboxMessages.keys.toList(); store.takeOutboxMessage(localMessageIds.removeAt(5)); check(store.outboxMessages).keys.deepEquals(localMessageIds); - checkNotNotified(); // TODO once (it disappears) + checkNotifiedOnce(); }); group('reconcileMessages', () { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 5ba2712e76..01e40cf7cf 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -15,6 +15,7 @@ import 'package:zulip/api/route/channels.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.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -1625,6 +1626,42 @@ void main() { }); }); + group('OutboxMessageWithPossibleSender', () { + final stream = eg.stream(); + final topic = 'topic'; + final topicNarrow = eg.topicNarrow(stream.streamId, topic); + const content = 'outbox message content'; + + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + + Finder outboxMessageFinder = find.widgetWithText( + OutboxMessageWithPossibleSender, content, skipOffstage: true); + + Future sendMessageAndSucceed(WidgetTester tester, { + Duration delay = Duration.zero, + }) async { + connection.prepare(json: SendMessageResult(id: 1).toJson(), delay: delay); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + // State transitions are tested more thoroughly in + // test/model/message_test.dart . + + testWidgets('hidden -> waiting, outbox message appear', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + await sendMessageAndSucceed(tester); + check(outboxMessageFinder).findsNothing(); + + await tester.pump(kLocalEchoDebounceDuration); + check(outboxMessageFinder).findsOne(); + }); + }); + group('Starred messages', () { testWidgets('unstarred message', (tester) async { final message = eg.streamMessage(flags: []); From 7370abd79dcd3df05789b1bbacfa22fd25451273 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 28 Feb 2025 20:10:58 +0530 Subject: [PATCH 157/290] pigeon [nfc]: Rename `notifications.dart` to `android_notifications.dart` This makes it clear that these bindings are for Android only. --- ...cations.g.kt => AndroidNotifications.g.kt} | 44 +++++++++---------- ...ations.dart => android_notifications.dart} | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) rename android/app/src/main/kotlin/com/zulip/flutter/{Notifications.g.kt => AndroidNotifications.g.kt} (94%) rename pigeon/{notifications.dart => android_notifications.dart} (99%) diff --git a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt similarity index 94% rename from android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt rename to android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt index f4862e2b0b..39207e3470 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt @@ -13,7 +13,7 @@ import io.flutter.plugin.common.StandardMethodCodec import io.flutter.plugin.common.StandardMessageCodec import java.io.ByteArrayOutputStream import java.nio.ByteBuffer -private object NotificationsPigeonUtils { +private object AndroidNotificationsPigeonUtils { fun wrapResult(result: Any?): List { return listOf(result) @@ -128,7 +128,7 @@ data class NotificationChannel ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -171,7 +171,7 @@ data class AndroidIntent ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -215,7 +215,7 @@ data class PendingIntent ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -249,7 +249,7 @@ data class InboxStyle ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -299,7 +299,7 @@ data class Person ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -339,7 +339,7 @@ data class MessagingStyleMessage ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -382,7 +382,7 @@ data class MessagingStyle ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -419,7 +419,7 @@ data class Notification ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -459,7 +459,7 @@ data class StatusBarNotification ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -509,11 +509,11 @@ data class StoredNotificationSound ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } -private open class NotificationsPigeonCodec : StandardMessageCodec() { +private open class AndroidNotificationsPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { @@ -721,7 +721,7 @@ interface AndroidNotificationHostApi { companion object { /** The codec used by AndroidNotificationHostApi. */ val codec: MessageCodec by lazy { - NotificationsPigeonCodec() + AndroidNotificationsPigeonCodec() } /** Sets up an instance of `AndroidNotificationHostApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads @@ -737,7 +737,7 @@ interface AndroidNotificationHostApi { api.createNotificationChannel(channelArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -752,7 +752,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getNotificationChannels()) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -770,7 +770,7 @@ interface AndroidNotificationHostApi { api.deleteNotificationChannel(channelIdArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -785,7 +785,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.listStoredSoundsInNotificationsDirectory()) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -803,7 +803,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -835,7 +835,7 @@ interface AndroidNotificationHostApi { api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -852,7 +852,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getActiveNotificationMessagingStyleByTag(tagArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -869,7 +869,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getActiveNotifications(desiredExtrasArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -888,7 +888,7 @@ interface AndroidNotificationHostApi { api.cancel(tagArg, idArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } diff --git a/pigeon/notifications.dart b/pigeon/android_notifications.dart similarity index 99% rename from pigeon/notifications.dart rename to pigeon/android_notifications.dart index 708ae4efb5..901369001c 100644 --- a/pigeon/notifications.dart +++ b/pigeon/android_notifications.dart @@ -4,7 +4,7 @@ import 'package:pigeon/pigeon.dart'; // run `tools/check pigeon --fix`. @ConfigurePigeon(PigeonOptions( dartOut: 'lib/host/android_notifications.g.dart', - kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt', kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), )) From 39fee00611c7283b15354a47bb628a225a40d15d Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 27 Mar 2025 20:22:38 +0530 Subject: [PATCH 158/290] dialog [nfc]: Document required ancestors for BuildContext And fix a typo. --- lib/widgets/dialog.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 08ce8f08c7..4d269cddba 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -52,10 +52,12 @@ class DialogStatus { /// /// Prose in [message] should have final punctuation: /// https://github.com/zulip/zulip-flutter/pull/1498#issuecomment-2853578577 +/// +/// The context argument should be a descendant of the app's main [Navigator]. // This API is inspired by [ScaffoldManager.showSnackBar]. We wrap // [showDialog]'s return value, a [Future], inside [DialogStatus] // whose documentation can be accessed. This helps avoid confusion when -// intepreting the meaning of the [Future]. +// interpreting the meaning of the [Future]. DialogStatus showErrorDialog({ required BuildContext context, required String title, @@ -86,6 +88,8 @@ DialogStatus showErrorDialog({ /// If the dialog was canceled, /// either with the cancel button or by tapping outside the dialog's area, /// it completes with null. +/// +/// The context argument should be a descendant of the app's main [Navigator]. DialogStatus showSuggestedActionDialog({ required BuildContext context, required String title, From 6fe4af623438904468800e51b657b8af6aa568a8 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 28 Mar 2025 19:32:30 +0530 Subject: [PATCH 159/290] binding test [nfc]: Reorder androidNotificationHost getter --- test/model/binding.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/model/binding.dart b/test/model/binding.dart index 2c70b68826..6b4de26608 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -313,12 +313,10 @@ class TestZulipBinding extends ZulipBinding { _androidNotificationHostApi = null; } - FakeAndroidNotificationHostApi? _androidNotificationHostApi; - @override - FakeAndroidNotificationHostApi get androidNotificationHost { - return (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); - } + FakeAndroidNotificationHostApi get androidNotificationHost => + (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); + FakeAndroidNotificationHostApi? _androidNotificationHostApi; /// The value that `ZulipBinding.instance.pickFiles()` should return. /// From 9601d7b5c7a884e7d61a1ed020ced00e88c096c5 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 10 Mar 2025 16:34:32 +0530 Subject: [PATCH 160/290] docs: Document testing push notifications on iOS Simulator --- .../howto/push-notifications-ios-simulator.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/howto/push-notifications-ios-simulator.md diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md new file mode 100644 index 0000000000..ff9fd3b370 --- /dev/null +++ b/docs/howto/push-notifications-ios-simulator.md @@ -0,0 +1,281 @@ +# Testing Push Notifications on iOS Simulator + +For documentation on testing push notifications on Android or a real +iOS Device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md + +This doc describes how to test client side changes on iOS Simulator. +It will demonstrate how to use APNs payloads the server sends to +Apple's Push Notification service to show notifications on iOS +Simulator. + +
+ +## Receive a notification on the iOS Simulator + +Follow the following steps if you already have a valid APNs payload. + +Otherwise, you can either use one of the pre-canned payloads +provided [here](#pre-canned-payloads), or you can retrieve the APNs +payload generated by the latest dev server by following the steps +[here](#retrieve-apns-payload). + + +### 1. Determine the device ID of the iOS Simulator + +To receive a notification on the iOS Simulator, we need to first +determine the device ID of the iOS Simulator, to specify which +Simulator instance we want to push the payload to. + +```shell-session +$ xcrun simctl list devices booted +``` + +
+Example output: + +```shell-session +$ xcrun simctl list devices booted +== Devices == +-- iOS 18.3 -- + iPhone 16 Pro (90CC33B2-679B-4053-B380-7B986A29F28C) (Booted) +``` + +
+ + +### 2. Trigger a notification by pushing the payload to the iOS Simulator + +By running the following command with a valid APNs payload, you should +receive a notification on the iOS Simulator for the zulip-flutter app, +and tapping on it should route to the respective conversation. + +```shell-session +$ xcrun simctl push [device-id] com.zulip.flutter [payload json path] +``` + +
+Example output: + +```shell-session +$ xcrun simctl push 90CC33B2-679B-4053-B380-7B986A29F28C com.zulip.flutter ./dm.json +Notification sent to 'com.zulip.flutter' +``` + +
+ + +
+ +## Pre-canned APNs payloads + +The following pre-canned APNs payloads can be used in case you don't +have one. + +The following pre-canned payloads were generated from +Zulip Server 11.0-dev+git 8fd04b0f0, API Feature Level 377, +in April 2025. + +Also, they assume that EXTERNAL_HOST has its default value for the dev +server. If you've [set EXTERNAL_HOST to use an IP address](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md#4-set-external_host) +in order to enable your device to connect to the dev server, you'll +need to adjust the `realm_url` fields. You can do this by a +find-and-replace for `localhost`; for example, +`perl -i -0pe s/localhost/10.0.2.2/g tmp/*.json` after saving the +canned payloads to files `tmp/*.json`. + +
+Payload: dm.json + +```json +{ + "aps": { + "alert": { + "title": "Zoe", + "subtitle": "", + "body": "But wouldn't that show you contextually who is in the audience before you have to open the compose box?" + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 7, + "sender_email": "user7@zulipdev.com", + "time": 1740890583, + "recipient_type": "private", + "message_ids": [ + 87 + ] + } +} +``` + +
+ +
+Payload: group_dm.json + +```json +{ + "aps": { + "alert": { + "title": "Othello, the Moor of Venice, Polonius (guest), Iago", + "subtitle": "Othello, the Moor of Venice:", + "body": "Sit down awhile; And let us once again assail your ears, That are so fortified against our story What we have two nights seen." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 12, + "sender_email": "user12@zulipdev.com", + "time": 1740533641, + "recipient_type": "private", + "pm_users": "11,12,13", + "message_ids": [ + 17 + ] + } +} +``` + +
+ +
+Payload: stream.json + +```json +{ + "aps": { + "alert": { + "title": "#devel > plotter", + "subtitle": "Desdemona:", + "body": "Despite the fact that such a claim at first glance seems counterintuitive, it is derived from known results. Electrical engineering follows a cycle of four phases: location, refinement, visualization, and evaluation." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 9, + "sender_email": "user9@zulipdev.com", + "time": 1740558997, + "recipient_type": "stream", + "stream": "devel", + "stream_id": 11, + "topic": "plotter", + "message_ids": [ + 40 + ] + } +} +``` + +
+ + +
+ +## Retrieve an APNs payload from dev server + +### 1. Set up dev server + +Follow +[this setup tutorial](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) +to setup and run the dev server on same the Mac machine that hosts +the iOS Simulator. + +If you want to run the dev server on a different machine than the Mac +host, you'll need to follow extra steps +[documented here](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md) +to make it possible for the app running on the iOS Simulator to +connect to the dev server. + + +### 2. Set up the dev user to receive mobile notifications. + +We'll use the devlogin user `iago@zulip.com` to test notifications, +log in to that user by going to `/devlogin` on that server on Web. + +And then follow the steps [here](https://zulip.com/help/mobile-notifications) +to enable Mobile Notifications for "Channels". + + +### 3. Log in as the dev user on zulip-flutter. + + + +To login to this user in the Flutter app, you'll need the password +that was generated by the development server. You can print the +password by running this command inside your `vagrant ssh` shell: +``` +$ ./manage.py print_initial_password iago@zulip.com +``` + +Then run the app on the iOS Simulator, accept the permission to +receive push notifications, and then login to the dev user +(`iago@zulip.com`). + + +### 4. Edit the server code to log the notification payload. + +We need to retrieve the APNs payload the server generates and sends +to the bouncer. To do that we can add a log statement after the +server completes generating the APNs in `zerver/lib/push_notifications.py`: + +```diff + apns_payload = get_message_payload_apns( + user_profile, + message, + trigger, + mentioned_user_group_id, + mentioned_user_group_name, + can_access_sender, + ) + gcm_payload, gcm_options = get_message_payload_gcm( + user_profile, message, mentioned_user_group_id, mentioned_user_group_name, can_access_sender + ) + logger.info("Sending push notifications to mobile clients for user %s", user_profile_id) ++ logger.info("APNS payload %s", orjson.dumps(apns_payload).decode()) + + android_devices = list( + PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.FCM).order_by("id") +``` + + +### 5. Send messages to the dev user + +To generate notifications to the dev user `iago@zulip.com` we need to +send messages from another user. For a variety of different types of +payloads try sending a message in a topic, a message in a group DM, +and one in one-one DM. Then look for the payloads in the server logs +by searching for "APNS payload". + + +### 6. Transform and save the payload to a file + +The logged payload JSON will have different structure than what an +iOS device actually receives, to fix that and save the result to a +file, run the payload through the following command: + +```shell-session +$ echo '{"alert":{"title": ...' \ + | jq '{aps: {alert: .alert, sound: .sound, badge: .badge}, zulip: .custom.zulip}' \ + > payload.json +``` From 7caf2c47c754ff65e3817f7ac9b7282baf067461 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 17 May 2025 21:20:02 -0700 Subject: [PATCH 161/290] docs: Clarify and expand a few spots in the iOS simulator notif doc Also make use of a handy shorthand within `jq`. --- .../howto/push-notifications-ios-simulator.md | 85 ++++++++++++------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md index ff9fd3b370..d54bb20491 100644 --- a/docs/howto/push-notifications-ios-simulator.md +++ b/docs/howto/push-notifications-ios-simulator.md @@ -1,23 +1,37 @@ # Testing Push Notifications on iOS Simulator For documentation on testing push notifications on Android or a real -iOS Device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md +iOS device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md -This doc describes how to test client side changes on iOS Simulator. +This doc describes how to test client-side changes on iOS Simulator. It will demonstrate how to use APNs payloads the server sends to -Apple's Push Notification service to show notifications on iOS +the Apple Push Notification service to show notifications on iOS Simulator. -
-## Receive a notification on the iOS Simulator +### Contents -Follow the following steps if you already have a valid APNs payload. +* [Trigger a notification on the iOS Simulator](#trigger-notification) +* [Canned APNs payloads](#canned-payloads) +* [Produce sample APNs payloads](#produce-payload) -Otherwise, you can either use one of the pre-canned payloads -provided [here](#pre-canned-payloads), or you can retrieve the APNs -payload generated by the latest dev server by following the steps -[here](#retrieve-apns-payload). + +
+ +## Trigger a notification on the iOS Simulator + +The iOS Simulator permits delivering a notification payload +artificially, as if APNs had delivered it to the device, +but without actually involving APNs or any other server. + +As input for this operation, you'll need an APNs payload, +i.e. a JSON blob representing what APNs might deliver to the app +for a notification. + +To get an APNs payload, you can generate one from a Zulip dev server +by following the [instructions in a section below](#produce-payload), +or you can use one of the payloads included +in this document [below](#canned-payloads). ### 1. Determine the device ID of the iOS Simulator @@ -46,8 +60,8 @@ $ xcrun simctl list devices booted ### 2. Trigger a notification by pushing the payload to the iOS Simulator By running the following command with a valid APNs payload, you should -receive a notification on the iOS Simulator for the zulip-flutter app, -and tapping on it should route to the respective conversation. +receive a notification on the iOS Simulator for the zulip-flutter app. +Tapping on the notification should route to the respective conversation. ```shell-session $ xcrun simctl push [device-id] com.zulip.flutter [payload json path] @@ -64,19 +78,21 @@ Notification sent to 'com.zulip.flutter' -
+
-## Pre-canned APNs payloads +## Canned APNs payloads The following pre-canned APNs payloads can be used in case you don't have one. -The following pre-canned payloads were generated from +These canned payloads were generated from Zulip Server 11.0-dev+git 8fd04b0f0, API Feature Level 377, in April 2025. +The `user_id` is that of `iago@zulip.com` in the Zulip dev environment. -Also, they assume that EXTERNAL_HOST has its default value for the dev -server. If you've [set EXTERNAL_HOST to use an IP address](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md#4-set-external_host) +These canned payloads assume that EXTERNAL_HOST has its default value +for the dev server. If you've +[set EXTERNAL_HOST to use an IP address](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md#4-set-external_host) in order to enable your device to connect to the dev server, you'll need to adjust the `realm_url` fields. You can do this by a find-and-replace for `localhost`; for example, @@ -190,16 +206,16 @@ canned payloads to files `tmp/*.json`. -
+
-## Retrieve an APNs payload from dev server +## Produce sample APNs payloads ### 1. Set up dev server -Follow -[this setup tutorial](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) -to setup and run the dev server on same the Mac machine that hosts -the iOS Simulator. +To set up and run the dev server on the same Mac machine that hosts +the iOS Simulator, follow Zulip's +[standard instructions](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) +for setting up a dev server. If you want to run the dev server on a different machine than the Mac host, you'll need to follow extra steps @@ -210,10 +226,10 @@ connect to the dev server. ### 2. Set up the dev user to receive mobile notifications. -We'll use the devlogin user `iago@zulip.com` to test notifications, -log in to that user by going to `/devlogin` on that server on Web. +We'll use the devlogin user `iago@zulip.com` to test notifications. +Log in to that user by going to `/devlogin` on that server on Web. -And then follow the steps [here](https://zulip.com/help/mobile-notifications) +Then follow the steps [here](https://zulip.com/help/mobile-notifications) to enable Mobile Notifications for "Channels". @@ -221,7 +237,7 @@ to enable Mobile Notifications for "Channels". -To login to this user in the Flutter app, you'll need the password +To log in as this user in the Flutter app, you'll need the password that was generated by the development server. You can print the password by running this command inside your `vagrant ssh` shell: ``` @@ -229,7 +245,7 @@ $ ./manage.py print_initial_password iago@zulip.com ``` Then run the app on the iOS Simulator, accept the permission to -receive push notifications, and then login to the dev user +receive push notifications, and then log in as the dev user (`iago@zulip.com`). @@ -237,7 +253,7 @@ receive push notifications, and then login to the dev user We need to retrieve the APNs payload the server generates and sends to the bouncer. To do that we can add a log statement after the -server completes generating the APNs in `zerver/lib/push_notifications.py`: +server completes generating the payload in `zerver/lib/push_notifications.py`: ```diff apns_payload = get_message_payload_apns( @@ -270,12 +286,15 @@ by searching for "APNS payload". ### 6. Transform and save the payload to a file -The logged payload JSON will have different structure than what an -iOS device actually receives, to fix that and save the result to a -file, run the payload through the following command: +The payload JSON recorded in the steps above is in the form the +Zulip server sends to the bouncer. The bouncer restructures this +slightly to produce the actual payload which it sends to APNs, +and which APNs delivers to the app on the device. +To apply the same restructuring, run the payload through +the following `jq` command: ```shell-session $ echo '{"alert":{"title": ...' \ - | jq '{aps: {alert: .alert, sound: .sound, badge: .badge}, zulip: .custom.zulip}' \ + | jq '{aps: {alert, sound, badge}, zulip: .custom.zulip}' \ > payload.json ``` From 9719415daaf9efbbf558d48cc5b5060ec41fb5ab Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 11 Apr 2025 19:55:12 -0700 Subject: [PATCH 162/290] store: Add "blocking future" option on GlobalStoreWidget --- lib/widgets/store.dart | 11 ++++++++ test/widgets/store_test.dart | 54 ++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index ab287b745a..f5fe4d3cc6 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -18,10 +18,18 @@ import 'page.dart'; class GlobalStoreWidget extends StatefulWidget { const GlobalStoreWidget({ super.key, + this.blockingFuture, this.placeholder = const LoadingPlaceholder(), required this.child, }); + /// An additional future to await before showing the child. + /// + /// If [blockingFuture] is non-null, then this widget will build [child] + /// only after the future completes. This widget's behavior is not affected + /// by whether the future's completion is with a value or with an error. + final Future? blockingFuture; + final Widget placeholder; final Widget child; @@ -87,6 +95,9 @@ class _GlobalStoreWidgetState extends State { super.initState(); (() async { final store = await ZulipBinding.instance.getGlobalStoreUniquely(); + if (widget.blockingFuture != null) { + await widget.blockingFuture!.catchError((_) {}); + } setState(() { this.store = store; }); diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index f8da5e24a0..54490ede93 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -70,12 +70,12 @@ void main() { return const SizedBox.shrink(); }))); // First, shows a loading page instead of child. - check(tester.any(find.byType(CircularProgressIndicator))).isTrue(); + check(find.byType(CircularProgressIndicator)).findsOne(); check(globalStore).isNull(); await tester.pump(); // Then after loading, mounts child instead, with provided store. - check(tester.any(find.byType(CircularProgressIndicator))).isFalse(); + check(find.byType(CircularProgressIndicator)).findsNothing(); check(globalStore).identicalTo(testBinding.globalStore); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -84,6 +84,56 @@ void main() { .equals((accountId: eg.selfAccount.id, account: eg.selfAccount)); }); + testWidgets('GlobalStoreWidget awaits blockingFuture', (tester) async { + addTearDown(testBinding.reset); + + final completer = Completer(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + blockingFuture: completer.future, + child: Text('done')))); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Even after the store must have loaded, + // still shows loading page while blockingFuture is pending. + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes… + completer.complete(); + await tester.pump(); + // … mounts child instead of the loading page. + check(find.byType(CircularProgressIndicator)).findsNothing(); + check(find.text('done')).findsOne(); + }); + + testWidgets('GlobalStoreWidget handles failed blockingFuture like success', (tester) async { + addTearDown(testBinding.reset); + + final completer = Completer(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + blockingFuture: completer.future, + child: Text('done')))); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Even after the store must have loaded, + // still shows loading page while blockingFuture is pending. + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes, even with an error… + completer.completeError(Exception('oops')); + await tester.pump(); + // … mounts child instead of the loading page. + check(find.byType(CircularProgressIndicator)).findsNothing(); + check(find.text('done')).findsOne(); + }); + testWidgets('GlobalStoreWidget.of updates dependents', (tester) async { addTearDown(testBinding.reset); From 5ac19e24820c89cca73b9da10fa24e75fa6e653c Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 28 May 2025 04:11:48 +0530 Subject: [PATCH 163/290] notif [nfc]: Move NotificationOpenPayload to a separate file --- lib/notifications/display.dart | 84 +---------- lib/notifications/open.dart | 85 +++++++++++ test/notifications/display_test.dart | 205 +------------------------- test/notifications/open_test.dart | 212 +++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 287 deletions(-) create mode 100644 lib/notifications/open.dart create mode 100644 test/notifications/open_test.dart diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 6e2585e135..3fb9c51c7b 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -21,6 +21,7 @@ import '../widgets/message_list.dart'; import '../widgets/page.dart'; import '../widgets/store.dart'; import '../widgets/theme.dart'; +import 'open.dart'; AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost; @@ -550,86 +551,3 @@ class NotificationDisplayManager { return null; } } - -/// The information contained in 'zulip://notification/…' internal -/// Android intent data URL, used for notification-open flow. -class NotificationOpenPayload { - final Uri realmUrl; - final int userId; - final Narrow narrow; - - NotificationOpenPayload({ - required this.realmUrl, - required this.userId, - required this.narrow, - }); - - factory NotificationOpenPayload.parseUrl(Uri url) { - if (url case Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': var realmUrlStr, - 'user_id': var userIdStr, - 'narrow_type': var narrowType, - // In case of narrowType == 'topic': - // 'channel_id' and 'topic' handled below. - - // In case of narrowType == 'dm': - // 'all_recipient_ids' handled below. - }, - )) { - final realmUrl = Uri.parse(realmUrlStr); - final userId = int.parse(userIdStr, radix: 10); - - final Narrow narrow; - switch (narrowType) { - case 'topic': - final channelIdStr = url.queryParameters['channel_id']!; - final channelId = int.parse(channelIdStr, radix: 10); - final topicStr = url.queryParameters['topic']!; - narrow = TopicNarrow(channelId, TopicName(topicStr)); - case 'dm': - final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; - final allRecipientIds = allRecipientIdsStr.split(',') - .map((idStr) => int.parse(idStr, radix: 10)) - .toList(growable: false); - narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); - default: - throw const FormatException(); - } - - return NotificationOpenPayload( - realmUrl: realmUrl, - userId: userId, - narrow: narrow, - ); - } else { - // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 - throw const FormatException(); - } - } - - Uri buildUrl() { - return Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': realmUrl.toString(), - 'user_id': userId.toString(), - ...(switch (narrow) { - TopicNarrow(streamId: var channelId, :var topic) => { - 'narrow_type': 'topic', - 'channel_id': channelId.toString(), - 'topic': topic.apiName, - }, - DmNarrow(:var allRecipientIds) => { - 'narrow_type': 'dm', - 'all_recipient_ids': allRecipientIds.join(','), - }, - _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), - }) - }, - ); - } -} diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart new file mode 100644 index 0000000000..d087109d17 --- /dev/null +++ b/lib/notifications/open.dart @@ -0,0 +1,85 @@ +import '../api/model/model.dart'; +import '../model/narrow.dart'; + +/// The information contained in 'zulip://notification/…' internal +/// Android intent data URL, used for notification-open flow. +class NotificationOpenPayload { + final Uri realmUrl; + final int userId; + final Narrow narrow; + + NotificationOpenPayload({ + required this.realmUrl, + required this.userId, + required this.narrow, + }); + + factory NotificationOpenPayload.parseUrl(Uri url) { + if (url case Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': var realmUrlStr, + 'user_id': var userIdStr, + 'narrow_type': var narrowType, + // In case of narrowType == 'topic': + // 'channel_id' and 'topic' handled below. + + // In case of narrowType == 'dm': + // 'all_recipient_ids' handled below. + }, + )) { + final realmUrl = Uri.parse(realmUrlStr); + final userId = int.parse(userIdStr, radix: 10); + + final Narrow narrow; + switch (narrowType) { + case 'topic': + final channelIdStr = url.queryParameters['channel_id']!; + final channelId = int.parse(channelIdStr, radix: 10); + final topicStr = url.queryParameters['topic']!; + narrow = TopicNarrow(channelId, TopicName(topicStr)); + case 'dm': + final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; + final allRecipientIds = allRecipientIdsStr.split(',') + .map((idStr) => int.parse(idStr, radix: 10)) + .toList(growable: false); + narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); + default: + throw const FormatException(); + } + + return NotificationOpenPayload( + realmUrl: realmUrl, + userId: userId, + narrow: narrow, + ); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + Uri buildUrl() { + return Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': realmUrl.toString(), + 'user_id': userId.toString(), + ...(switch (narrow) { + TopicNarrow(streamId: var channelId, :var topic) => { + 'narrow_type': 'topic', + 'channel_id': channelId.toString(), + 'topic': topic.apiName, + }, + DmNarrow(:var allRecipientIds) => { + 'narrow_type': 'dm', + 'all_recipient_ids': allRecipientIds.join(','), + }, + _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), + }) + }, + ); + } +} diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index ffb5d345d8..b991d8bfec 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -18,6 +18,7 @@ import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/display.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/color.dart'; @@ -29,8 +30,6 @@ import 'package:zulip/widgets/theme.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; import '../model/binding.dart'; -import '../model/narrow_checks.dart'; -import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; import '../widgets/dialog_checks.dart'; @@ -1255,202 +1254,6 @@ void main() { matchesNavigation(check(pushedRoutes).single, accountB, message); }); }); - - group('NotificationOpenPayload', () { - test('smoke round-trip', () { - // DM narrow - var payload = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ); - var url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(payload.realmUrl) - ..userId.equals(payload.userId) - ..narrow.equals(payload.narrow); - - // Topic narrow - payload = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ); - url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(payload.realmUrl) - ..userId.equals(payload.userId) - ..narrow.equals(payload.narrow); - }); - - test('buildUrl: smoke DM', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - }); - - test('buildUrl: smoke topic', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - }); - - test('parse: smoke DM', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..allRecipientIds.deepEquals([1001, 1002]) - ..otherRecipientIds.deepEquals([1002])); - }); - - test('parse: smoke topic', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..streamId.equals(1) - ..topic.equals(eg.t('topic A'))); - }); - - test('parse: fails when missing any expected query parameters', () { - final testCases = >[ - { - // 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - // 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - // 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - // 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - // 'all_recipient_ids': '1001,1002', - }, - ]; - for (final params in testCases) { - check(() => NotificationOpenPayload.parseUrl(Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: params, - ))) - // Missing 'realm_url', 'user_id' and 'narrow_type' - // throws 'FormatException'. - // Missing 'channel_id', 'topic', when narrow_type == 'topic' - // throws 'TypeError'. - // Missing 'all_recipient_ids', when narrow_type == 'dm' - // throws 'TypeError'. - .throws(); - } - }); - - test('parse: fails when scheme is not "zulip"', () { - final url = Uri( - scheme: 'http', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); - - test('parse: fails when host is not "notification"', () { - final url = Uri( - scheme: 'zulip', - host: 'example', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); - }); } extension on Subject { @@ -1530,9 +1333,3 @@ extension on Subject { Subject get notification => has((x) => x.notification, 'notification'); Subject get tag => has((x) => x.tag, 'tag'); } - -extension on Subject { - Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); - Subject get userId => has((x) => x.userId, 'userId'); - Subject get narrow => has((x) => x.narrow, 'narrow'); -} diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart new file mode 100644 index 0000000000..c9960118b6 --- /dev/null +++ b/test/notifications/open_test.dart @@ -0,0 +1,212 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/notifications/open.dart'; + +import '../example_data.dart' as eg; +import '../model/narrow_checks.dart'; +import '../stdlib_checks.dart'; + +void main() { + group('NotificationOpenPayload', () { + test('smoke round-trip', () { + // DM narrow + var payload = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), + ); + var url = payload.buildUrl(); + check(NotificationOpenPayload.parseUrl(url)) + ..realmUrl.equals(payload.realmUrl) + ..userId.equals(payload.userId) + ..narrow.equals(payload.narrow); + + // Topic narrow + payload = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: eg.topicNarrow(1, 'topic A'), + ); + url = payload.buildUrl(); + check(NotificationOpenPayload.parseUrl(url)) + ..realmUrl.equals(payload.realmUrl) + ..userId.equals(payload.userId) + ..narrow.equals(payload.narrow); + }); + + test('buildUrl: smoke DM', () { + final url = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), + ).buildUrl(); + check(url) + ..scheme.equals('zulip') + ..host.equals('notification') + ..queryParameters.deepEquals({ + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }); + }); + + test('buildUrl: smoke topic', () { + final url = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: eg.topicNarrow(1, 'topic A'), + ).buildUrl(); + check(url) + ..scheme.equals('zulip') + ..host.equals('notification') + ..queryParameters.deepEquals({ + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + }); + + test('parse: smoke DM', () { + final url = Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }); + check(NotificationOpenPayload.parseUrl(url)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..allRecipientIds.deepEquals([1001, 1002]) + ..otherRecipientIds.deepEquals([1002])); + }); + + test('parse: smoke topic', () { + final url = Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(NotificationOpenPayload.parseUrl(url)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..streamId.equals(1) + ..topic.equals(eg.t('topic A'))); + }); + + test('parse: fails when missing any expected query parameters', () { + final testCases = >[ + { + // 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + // 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + // 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + // 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + // 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + // 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + // 'all_recipient_ids': '1001,1002', + }, + ]; + for (final params in testCases) { + check(() => NotificationOpenPayload.parseUrl(Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: params, + ))) + // Missing 'realm_url', 'user_id' and 'narrow_type' + // throws 'FormatException'. + // Missing 'channel_id', 'topic', when narrow_type == 'topic' + // throws 'TypeError'. + // Missing 'all_recipient_ids', when narrow_type == 'dm' + // throws 'TypeError'. + .throws(); + } + }); + + test('parse: fails when scheme is not "zulip"', () { + final url = Uri( + scheme: 'http', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(() => NotificationOpenPayload.parseUrl(url)) + .throws(); + }); + + test('parse: fails when host is not "notification"', () { + final url = Uri( + scheme: 'zulip', + host: 'example', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(() => NotificationOpenPayload.parseUrl(url)) + .throws(); + }); + }); +} + +extension on Subject { + Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); + Subject get userId => has((x) => x.userId, 'userId'); + Subject get narrow => has((x) => x.narrow, 'narrow'); +} From b29b926d5431ad620b5a581c21eafde3e249c905 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 28 May 2025 04:32:08 +0530 Subject: [PATCH 164/290] notif [nfc]: Introduce NotificationOpenService And move the notification navigation data parsing utilities to the new class. --- lib/notifications/display.dart | 63 ------- lib/notifications/open.dart | 75 ++++++++ lib/widgets/app.dart | 6 +- test/notifications/display_test.dart | 231 ------------------------ test/notifications/open_test.dart | 253 +++++++++++++++++++++++++++ 5 files changed, 331 insertions(+), 297 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 3fb9c51c7b..5b73c0b05c 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -3,23 +3,16 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart' hide Notification; import 'package:http/http.dart' as http; import '../api/model/model.dart'; import '../api/notifications.dart'; -import '../generated/l10n/zulip_localizations.dart'; import '../host/android_notifications.dart'; import '../log.dart'; import '../model/binding.dart'; import '../model/localizations.dart'; import '../model/narrow.dart'; -import '../widgets/app.dart'; import '../widgets/color.dart'; -import '../widgets/dialog.dart'; -import '../widgets/message_list.dart'; -import '../widgets/page.dart'; -import '../widgets/store.dart'; import '../widgets/theme.dart'; import 'open.dart'; @@ -482,62 +475,6 @@ 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, - }) { - assert(defaultTargetPlatform == TargetPlatform.android); - - 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.origin == payload.realmUrl.origin - && account.userId == payload.userId); - if (account == null) { // TODO(log) - final zulipLocalizations = ZulipLocalizations.of(context); - showErrorDialog(context: context, - title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountNotFound); - return null; - } - - return MessageListPage.buildRoute( - accountId: account.id, - // TODO(#1565): 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 - /// the notification. - static Future navigateForNotification(Uri url) async { - assert(defaultTargetPlatform == TargetPlatform.android); - assert(debugLog('opened notif: url: $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 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(route)); - } - static Future _fetchBitmap(Uri url) async { try { // TODO timeout to prevent waiting indefinitely diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index d087109d17..50f3998054 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -1,5 +1,80 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../log.dart'; import '../model/narrow.dart'; +import '../widgets/app.dart'; +import '../widgets/dialog.dart'; +import '../widgets/message_list.dart'; +import '../widgets/page.dart'; +import '../widgets/store.dart'; + +/// Responds to the user opening a notification. +class NotificationOpenService { + + /// 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. + /// + /// The context argument should be a descendant of the app's main [Navigator]. + static AccountRoute? routeForNotification({ + required BuildContext context, + required Uri url, + }) { + assert(defaultTargetPlatform == TargetPlatform.android); + + 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.origin == payload.realmUrl.origin + && account.userId == payload.userId); + if (account == null) { // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle, + message: zulipLocalizations.errorNotificationOpenAccountNotFound); + return null; + } + + return MessageListPage.buildRoute( + accountId: account.id, + // TODO(#1565): 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 + /// the notification. + static Future navigateForNotification(Uri url) async { + assert(defaultTargetPlatform == TargetPlatform.android); + assert(debugLog('opened notif: url: $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 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(route)); + } +} /// The information contained in 'zulip://notification/…' internal /// Android intent data URL, used for notification-open flow. diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 54ba92588b..8017bf67b0 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -9,7 +9,7 @@ import '../log.dart'; import '../model/actions.dart'; import '../model/localizations.dart'; import '../model/store.dart'; -import '../notifications/display.dart'; +import '../notifications/open.dart'; import 'about_zulip.dart'; import 'dialog.dart'; import 'home.dart'; @@ -176,7 +176,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { final initialRouteUrl = Uri.tryParse(initialRoute); if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - final route = NotificationDisplayManager.routeForNotification( + final route = NotificationOpenService.routeForNotification( context: context, url: initialRouteUrl); @@ -209,7 +209,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { await LoginPage.handleWebAuthUrl(url); return true; case Uri(scheme: 'zulip', host: 'notification') && var url: - await NotificationDisplayManager.navigateForNotification(url); + await NotificationOpenService.navigateForNotification(url); return true; } return super.didPushRouteInformation(routeInformation); diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index b991d8bfec..3cbb96308c 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -6,7 +5,6 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; import 'package:fake_async/fake_async.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart' hide Notification; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; @@ -20,21 +18,13 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/display.dart'; import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; -import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/color.dart'; -import 'package:zulip/widgets/home.dart'; -import 'package:zulip/widgets/message_list.dart'; -import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/theme.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; import '../model/binding.dart'; import '../test_images.dart'; -import '../test_navigation.dart'; -import '../widgets/dialog_checks.dart'; -import '../widgets/message_list_checks.dart'; -import '../widgets/page_checks.dart'; MessageFcmMessage messageFcmMessage( Message zulipMessage, { @@ -1033,227 +1023,6 @@ void main() { check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); }); - - group('NotificationDisplayManager open', () { - late List> pushedRoutes; - - void takeStartingRoutes({Account? account, bool withAccount = true}) { - account ??= eg.selfAccount; - final expected = >[ - if (withAccount) - (it) => it.isA() - ..accountId.equals(account!.id) - ..page.isA() - else - (it) => it.isA().page.isA(), - ]; - check(pushedRoutes.take(expected.length)).deepEquals(expected); - pushedRoutes.removeRange(0, expected.length); - } - - Future prepare(WidgetTester tester, - {bool early = false, bool withAccount = true}) async { - await init(addSelfAccount: false); - pushedRoutes = []; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - // This uses [ZulipApp] instead of [TestZulipApp] because notification - // logic uses `await ZulipApp.navigator`. - await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); - if (early) { - check(pushedRoutes).isEmpty(); - return; - } - await tester.pump(); - takeStartingRoutes(withAccount: withAccount); - check(pushedRoutes).isEmpty(); - } - - Future openNotification(WidgetTester tester, Account account, Message message) async { - final data = messageFcmMessage(message, account: account); - 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(); - unawaited( - WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); - await tester.idle(); // let navigateForNotification find navigator - } - - void matchesNavigation(Subject> route, Account account, Message message) { - route.isA() - ..accountId.equals(account.id) - ..page.isA() - .initNarrow.equals(SendableNarrow.ofMessage(message, - selfUserId: account.userId)); - } - - Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { - await openNotification(tester, account, message); - matchesNavigation(check(pushedRoutes).single, account, message); - pushedRoutes.clear(); - } - - testWidgets('stream message', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); - }); - - testWidgets('direct message', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await checkOpenNotification(tester, eg.selfAccount, - 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()); - await tester.pump(); - check(pushedRoutes.single).isA>(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); - }); - - testWidgets('mismatching account', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await openNotification(tester, eg.otherAccount, eg.streamMessage()); - await tester.pump(); - check(pushedRoutes.single).isA>(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); - }); - - testWidgets('find account among several', (tester) async { - addTearDown(testBinding.reset); - final realmUrlA = Uri.parse('https://a-chat.example/'); - final realmUrlB = Uri.parse('https://chat-b.example/'); - final user1 = eg.user(); - final user2 = eg.user(); - final accounts = [ - eg.account(id: 1001, realmUrl: realmUrlA, user: user1), - eg.account(id: 1002, realmUrl: realmUrlA, user: user2), - eg.account(id: 1003, realmUrl: realmUrlB, user: user1), - eg.account(id: 1004, realmUrl: realmUrlB, user: user2), - ]; - for (final account in accounts) { - await testBinding.globalStore.add(account, eg.initialSnapshot()); - } - await prepare(tester); - - await checkOpenNotification(tester, accounts[0], eg.streamMessage()); - await checkOpenNotification(tester, accounts[1], eg.streamMessage()); - await checkOpenNotification(tester, accounts[2], eg.streamMessage()); - await checkOpenNotification(tester, accounts[3], eg.streamMessage()); - }); - - testWidgets('wait for app to become ready', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester, early: true); - final message = eg.streamMessage(); - await openNotification(tester, eg.selfAccount, message); - // The app should still not be ready (or else this test won't work right). - check(ZulipApp.ready.value).isFalse(); - check(ZulipApp.navigatorKey.currentState).isNull(); - // And the openNotification hasn't caused any navigation yet. - check(pushedRoutes).isEmpty(); - - // Now let the GlobalStore get loaded and the app's main UI get mounted. - await tester.pump(); - // The navigator first pushes the starting routes… - takeStartingRoutes(); - // … and then the one the notification leads to. - matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); - }); - - testWidgets('at app launch', (tester) async { - addTearDown(testBinding.reset); - // Set up a value for `PlatformDispatcher.defaultRouteName` to return, - // for determining the intial route. - final account = eg.selfAccount; - final message = eg.streamMessage(); - final data = messageFcmMessage(message, account: account); - 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(); - - // Now start the app. - await testBinding.globalStore.add(account, eg.initialSnapshot()); - await prepare(tester, early: true); - check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet - - // Once the app is ready, we navigate to the conversation. - await tester.pump(); - 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); - }); - }); } extension on Subject { diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index c9960118b6..a08e999c1e 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -1,13 +1,266 @@ +import 'dart:async'; + import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/notifications.dart'; +import 'package:zulip/model/database.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/notifications/open.dart'; +import 'package:zulip/notifications/receive.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; import '../example_data.dart' as eg; +import '../model/binding.dart'; import '../model/narrow_checks.dart'; import '../stdlib_checks.dart'; +import '../test_navigation.dart'; +import '../widgets/dialog_checks.dart'; +import '../widgets/message_list_checks.dart'; +import '../widgets/page_checks.dart'; +import 'display_test.dart'; void main() { + TestZulipBinding.ensureInitialized(); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Future init({bool addSelfAccount = true}) async { + if (addSelfAccount) { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + } + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + addTearDown(NotificationService.debugReset); + NotificationService.debugBackgroundIsolateIsLive = false; + await NotificationService.instance.start(); + } + + group('NotificationOpenService', () { + late List> pushedRoutes; + + void takeStartingRoutes({Account? account, bool withAccount = true}) { + account ??= eg.selfAccount; + final expected = >[ + if (withAccount) + (it) => it.isA() + ..accountId.equals(account!.id) + ..page.isA() + else + (it) => it.isA().page.isA(), + ]; + check(pushedRoutes.take(expected.length)).deepEquals(expected); + pushedRoutes.removeRange(0, expected.length); + } + + Future prepare(WidgetTester tester, + {bool early = false, bool withAccount = true}) async { + await init(addSelfAccount: false); + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + // This uses [ZulipApp] instead of [TestZulipApp] because notification + // logic uses `await ZulipApp.navigator`. + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + if (early) { + check(pushedRoutes).isEmpty(); + return; + } + await tester.pump(); + takeStartingRoutes(withAccount: withAccount); + check(pushedRoutes).isEmpty(); + } + + Future openNotification(WidgetTester tester, Account account, Message message) async { + final data = messageFcmMessage(message, account: account); + 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(); + unawaited( + WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); + await tester.idle(); // let navigateForNotification find navigator + } + + void matchesNavigation(Subject> route, Account account, Message message) { + route.isA() + ..accountId.equals(account.id) + ..page.isA() + .initNarrow.equals(SendableNarrow.ofMessage(message, + selfUserId: account.userId)); + } + + Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { + await openNotification(tester, account, message); + matchesNavigation(check(pushedRoutes).single, account, message); + pushedRoutes.clear(); + } + + testWidgets('stream message', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); + }); + + testWidgets('direct message', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, + 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()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); + }); + + testWidgets('mismatching account', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await openNotification(tester, eg.otherAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); + }); + + testWidgets('find account among several', (tester) async { + addTearDown(testBinding.reset); + final realmUrlA = Uri.parse('https://a-chat.example/'); + final realmUrlB = Uri.parse('https://chat-b.example/'); + final user1 = eg.user(); + final user2 = eg.user(); + final accounts = [ + eg.account(id: 1001, realmUrl: realmUrlA, user: user1), + eg.account(id: 1002, realmUrl: realmUrlA, user: user2), + eg.account(id: 1003, realmUrl: realmUrlB, user: user1), + eg.account(id: 1004, realmUrl: realmUrlB, user: user2), + ]; + for (final account in accounts) { + await testBinding.globalStore.add(account, eg.initialSnapshot()); + } + await prepare(tester); + + await checkOpenNotification(tester, accounts[0], eg.streamMessage()); + await checkOpenNotification(tester, accounts[1], eg.streamMessage()); + await checkOpenNotification(tester, accounts[2], eg.streamMessage()); + await checkOpenNotification(tester, accounts[3], eg.streamMessage()); + }); + + testWidgets('wait for app to become ready', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester, early: true); + final message = eg.streamMessage(); + await openNotification(tester, eg.selfAccount, message); + // The app should still not be ready (or else this test won't work right). + check(ZulipApp.ready.value).isFalse(); + check(ZulipApp.navigatorKey.currentState).isNull(); + // And the openNotification hasn't caused any navigation yet. + check(pushedRoutes).isEmpty(); + + // Now let the GlobalStore get loaded and the app's main UI get mounted. + await tester.pump(); + // The navigator first pushes the starting routes… + takeStartingRoutes(); + // … and then the one the notification leads to. + matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); + }); + + testWidgets('at app launch', (tester) async { + addTearDown(testBinding.reset); + // Set up a value for `PlatformDispatcher.defaultRouteName` to return, + // for determining the intial route. + final account = eg.selfAccount; + final message = eg.streamMessage(); + final data = messageFcmMessage(message, account: account); + 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(); + + // Now start the app. + await testBinding.globalStore.add(account, eg.initialSnapshot()); + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + // Once the app is ready, we navigate to the conversation. + await tester.pump(); + 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', () { test('smoke round-trip', () { // DM narrow From 142939aaf900a2241ba2e2c911632ad539e458c5 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 28 May 2025 04:41:42 +0530 Subject: [PATCH 165/290] notif [nfc]: Rename NotificationOpenPayload methods To make it clear that they are Android specific. --- lib/notifications/display.dart | 2 +- lib/notifications/open.dart | 22 +- test/notifications/display_test.dart | 2 +- test/notifications/open_test.dart | 342 ++++++++++++++------------- 4 files changed, 188 insertions(+), 180 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 5b73c0b05c..0a3de1689a 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -296,7 +296,7 @@ class NotificationDisplayManager { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); await _androidHost.notify( id: kNotificationId, diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index 50f3998054..6c4272242b 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -19,8 +19,9 @@ class NotificationOpenService { /// 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. + /// The URL must have been generated using + /// [NotificationOpenPayload.buildAndroidNotificationUrl] while creating the + /// notification. /// /// Returns null and shows an error dialog if the associated account is not /// found in the global store. @@ -36,7 +37,7 @@ class NotificationOpenService { assert(debugLog('got notif: url: $url')); assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseUrl(url); + final payload = NotificationOpenPayload.parseAndroidNotificationUrl(url); final account = globalStore.accounts.firstWhereOrNull( (account) => account.realmUrl.origin == payload.realmUrl.origin @@ -57,8 +58,8 @@ class NotificationOpenService { /// Navigates to the [MessageListPage] of the specific conversation /// given the `zulip://notification/…` Android intent data URL, - /// generated with [NotificationOpenPayload.buildUrl] while creating - /// the notification. + /// generated with [NotificationOpenPayload.buildAndroidNotificationUrl] + /// while creating the notification. static Future navigateForNotification(Uri url) async { assert(defaultTargetPlatform == TargetPlatform.android); assert(debugLog('opened notif: url: $url')); @@ -76,8 +77,8 @@ class NotificationOpenService { } } -/// The information contained in 'zulip://notification/…' internal -/// Android intent data URL, used for notification-open flow. +/// The data from a notification that describes what to do +/// when the user opens the notification. class NotificationOpenPayload { final Uri realmUrl; final int userId; @@ -89,7 +90,10 @@ class NotificationOpenPayload { required this.narrow, }); - factory NotificationOpenPayload.parseUrl(Uri url) { + /// Parses the internal Android notification url, that was created using + /// [buildAndroidNotificationUrl], and retrieves the information required + /// for navigation. + factory NotificationOpenPayload.parseAndroidNotificationUrl(Uri url) { if (url case Uri( scheme: 'zulip', host: 'notification', @@ -135,7 +139,7 @@ class NotificationOpenPayload { } } - Uri buildUrl() { + Uri buildAndroidNotificationUrl() { return Uri( scheme: 'zulip', host: 'notification', diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 3cbb96308c..7fe04c73ee 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -343,7 +343,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); final messageStyleMessagesChecks = messageStyleMessages.mapIndexed((i, messageData) { diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index a08e999c1e..495b6b8d34 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -85,7 +85,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); unawaited( WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); await tester.idle(); // let navigateForNotification find navigator @@ -215,7 +215,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); @@ -248,7 +248,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); @@ -269,8 +269,8 @@ void main() { userId: 1001, narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), ); - var url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) + var url = payload.buildAndroidNotificationUrl(); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) ..realmUrl.equals(payload.realmUrl) ..userId.equals(payload.userId) ..narrow.equals(payload.narrow); @@ -281,179 +281,183 @@ void main() { userId: 1001, narrow: eg.topicNarrow(1, 'topic A'), ); - url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) + url = payload.buildAndroidNotificationUrl(); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) ..realmUrl.equals(payload.realmUrl) ..userId.equals(payload.userId) ..narrow.equals(payload.narrow); }); - test('buildUrl: smoke DM', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - }); - - test('buildUrl: smoke topic', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - }); - - test('parse: smoke DM', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..allRecipientIds.deepEquals([1001, 1002]) - ..otherRecipientIds.deepEquals([1002])); - }); - - test('parse: smoke topic', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..streamId.equals(1) - ..topic.equals(eg.t('topic A'))); + group('buildAndroidNotificationUrl', () { + test('smoke DM', () { + final url = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), + ).buildAndroidNotificationUrl(); + check(url) + ..scheme.equals('zulip') + ..host.equals('notification') + ..queryParameters.deepEquals({ + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }); + }); + + test('smoke topic', () { + final url = NotificationOpenPayload( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: eg.topicNarrow(1, 'topic A'), + ).buildAndroidNotificationUrl(); + check(url) + ..scheme.equals('zulip') + ..host.equals('notification') + ..queryParameters.deepEquals({ + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + }); }); - test('parse: fails when missing any expected query parameters', () { - final testCases = >[ - { - // 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - // 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - // 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - // 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - // 'all_recipient_ids': '1001,1002', - }, - ]; - for (final params in testCases) { - check(() => NotificationOpenPayload.parseUrl(Uri( + group('parseAndroidNotificationUrl', () { + test('smoke DM', () { + final url = Uri( scheme: 'zulip', host: 'notification', - queryParameters: params, - ))) - // Missing 'realm_url', 'user_id' and 'narrow_type' - // throws 'FormatException'. - // Missing 'channel_id', 'topic', when narrow_type == 'topic' - // throws 'TypeError'. - // Missing 'all_recipient_ids', when narrow_type == 'dm' - // throws 'TypeError'. - .throws(); - } - }); - - test('parse: fails when scheme is not "zulip"', () { - final url = Uri( - scheme: 'http', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); - - test('parse: fails when host is not "notification"', () { - final url = Uri( - scheme: 'zulip', - host: 'example', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..allRecipientIds.deepEquals([1001, 1002]) + ..otherRecipientIds.deepEquals([1002])); + }); + + test('smoke topic', () { + final url = Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..streamId.equals(1) + ..topic.equals(eg.t('topic A'))); + }); + + test('fails when missing any expected query parameters', () { + final testCases = >[ + { + // 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + // 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + // 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + // 'channel_id': '1', + 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + // 'topic': 'topic A', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + // 'narrow_type': 'dm', + 'all_recipient_ids': '1001,1002', + }, + { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'dm', + // 'all_recipient_ids': '1001,1002', + }, + ]; + for (final params in testCases) { + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: params, + ))) + // Missing 'realm_url', 'user_id' and 'narrow_type' + // throws 'FormatException'. + // Missing 'channel_id', 'topic', when narrow_type == 'topic' + // throws 'TypeError'. + // Missing 'all_recipient_ids', when narrow_type == 'dm' + // throws 'TypeError'. + .throws(); + } + }); + + test('fails when scheme is not "zulip"', () { + final url = Uri( + scheme: 'http', + host: 'notification', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url)) + .throws(); + }); + + test('fails when host is not "notification"', () { + final url = Uri( + scheme: 'zulip', + host: 'example', + queryParameters: { + 'realm_url': 'http://chat.example', + 'user_id': '1001', + 'narrow_type': 'topic', + 'channel_id': '1', + 'topic': 'topic A', + }); + check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url)) + .throws(); + }); }); }); } From cb1ddb955bab04bc11cd9c09228d62de8e1cb739 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 28 May 2025 05:04:36 +0530 Subject: [PATCH 166/290] notif [nfc]: Refactor NotificationOpenService.routeForNotification Update it to receive `NotificationOpenPayload` as an argument instead of the Android Notification URL. Also rename `navigateForNotification` to `navigateForAndroidNotificationUrl`, making it more explicit. --- lib/notifications/open.dart | 24 +++++++++------------- lib/widgets/app.dart | 40 ++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index 6c4272242b..66c77ef4d4 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -17,11 +17,7 @@ import '../widgets/store.dart'; /// Responds to the user opening a notification. class NotificationOpenService { - /// Provides the route and the account ID by parsing the notification URL. - /// - /// The URL must have been generated using - /// [NotificationOpenPayload.buildAndroidNotificationUrl] while creating the - /// notification. + /// Provides the route to open by parsing the notification payload. /// /// Returns null and shows an error dialog if the associated account is not /// found in the global store. @@ -29,19 +25,15 @@ class NotificationOpenService { /// The context argument should be a descendant of the app's main [Navigator]. static AccountRoute? routeForNotification({ required BuildContext context, - required Uri url, + required NotificationOpenPayload data, }) { assert(defaultTargetPlatform == TargetPlatform.android); final globalStore = GlobalStoreWidget.of(context); - assert(debugLog('got notif: url: $url')); - assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseAndroidNotificationUrl(url); - final account = globalStore.accounts.firstWhereOrNull( - (account) => account.realmUrl.origin == payload.realmUrl.origin - && account.userId == payload.userId); + (account) => account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId); if (account == null) { // TODO(log) final zulipLocalizations = ZulipLocalizations.of(context); showErrorDialog(context: context, @@ -53,14 +45,14 @@ class NotificationOpenService { return MessageListPage.buildRoute( accountId: account.id, // TODO(#1565): Open at specific message, not just conversation - narrow: payload.narrow); + narrow: data.narrow); } /// Navigates to the [MessageListPage] of the specific conversation /// given the `zulip://notification/…` Android intent data URL, /// generated with [NotificationOpenPayload.buildAndroidNotificationUrl] /// while creating the notification. - static Future navigateForNotification(Uri url) async { + static Future navigateForAndroidNotificationUrl(Uri url) async { assert(defaultTargetPlatform == TargetPlatform.android); assert(debugLog('opened notif: url: $url')); @@ -69,7 +61,9 @@ class NotificationOpenService { 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 route = routeForNotification(context: context, url: url); + assert(url.scheme == 'zulip' && url.host == 'notification'); + final data = NotificationOpenPayload.parseAndroidNotificationUrl(url); + final route = routeForNotification(context: context, data: data); if (route == null) return; // TODO(log) // TODO(nav): Better interact with existing nav stack on notif open diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 8017bf67b0..22ebadba03 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -168,27 +168,35 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } + AccountRoute? _initialRouteAndroid( + BuildContext context, + String initialRoute, + ) { + final initialRouteUrl = Uri.tryParse(initialRoute); + if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { + assert(debugLog('got notif: url: $initialRouteUrl')); + final data = + NotificationOpenPayload.parseAndroidNotificationUrl(initialRouteUrl); + return NotificationOpenService.routeForNotification( + context: context, + data: data); + } + + return null; + } + 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 initialRouteUrl = Uri.tryParse(initialRoute); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - final route = NotificationOpenService.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 route = _initialRouteAndroid(context, initialRoute); + if (route != null) { + return [ + HomePage.buildRoute(accountId: route.accountId), + route, + ]; } final globalStore = GlobalStoreWidget.of(context); @@ -209,7 +217,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { await LoginPage.handleWebAuthUrl(url); return true; case Uri(scheme: 'zulip', host: 'notification') && var url: - await NotificationOpenService.navigateForNotification(url); + await NotificationOpenService.navigateForAndroidNotificationUrl(url); return true; } return super.didPushRouteInformation(routeInformation); From 10f36914dfaecf2e8af9d6794bb47edf301b8889 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 28 May 2025 05:10:25 +0530 Subject: [PATCH 167/290] notif: Show a dialog if received malformed Android Notification URL --- lib/notifications/open.dart | 18 +++++++++++++++++- lib/widgets/app.dart | 6 ++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index 66c77ef4d4..9f4d2afd9c 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -62,13 +62,29 @@ class NotificationOpenService { 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 assert(url.scheme == 'zulip' && url.host == 'notification'); - final data = NotificationOpenPayload.parseAndroidNotificationUrl(url); + final data = tryParseAndroidNotificationUrl(context: context, url: url); + if (data == null) return; // TODO(log) final route = routeForNotification(context: context, data: data); if (route == null) return; // TODO(log) // TODO(nav): Better interact with existing nav stack on notif open unawaited(navigator.push(route)); } + + static NotificationOpenPayload? tryParseAndroidNotificationUrl({ + required BuildContext context, + required Uri url, + }) { + try { + return NotificationOpenPayload.parseAndroidNotificationUrl(url); + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } } /// The data from a notification that describes what to do diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 22ebadba03..96c546bd3f 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -175,8 +175,10 @@ class _ZulipAppState extends State with WidgetsBindingObserver { final initialRouteUrl = Uri.tryParse(initialRoute); if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { assert(debugLog('got notif: url: $initialRouteUrl')); - final data = - NotificationOpenPayload.parseAndroidNotificationUrl(initialRouteUrl); + final data = NotificationOpenService.tryParseAndroidNotificationUrl( + context: context, + url: initialRouteUrl); + if (data == null) return null; // TODO(log) return NotificationOpenService.routeForNotification( context: context, data: data); From 191152049337d226965ec04be9cafc7fbffcc9e1 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 28 May 2025 23:21:45 +0530 Subject: [PATCH 168/290] notif test [nfc]: Pull out androidNotificationUrlForMessage and setupNotificationDataForLaunch --- test/notifications/open_test.dart | 45 +++++++++++-------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index 495b6b8d34..4ed0c10fd6 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -75,9 +75,9 @@ void main() { check(pushedRoutes).isEmpty(); } - Future openNotification(WidgetTester tester, Account account, Message message) async { + Uri androidNotificationUrlForMessage(Account account, Message message) { final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( + return NotificationOpenPayload( realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { @@ -86,11 +86,23 @@ void main() { FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), }).buildAndroidNotificationUrl(); + } + + Future openNotification(WidgetTester tester, Account account, Message message) async { + final intentDataUrl = androidNotificationUrlForMessage(account, message); unawaited( WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); await tester.idle(); // let navigateForNotification find navigator } + void setupNotificationDataForLaunch(WidgetTester tester, Account account, Message message) { + // Set up a value for `PlatformDispatcher.defaultRouteName` to return, + // for determining the initial route. + final intentDataUrl = androidNotificationUrlForMessage(account, message); + addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + } + void matchesNavigation(Subject> route, Account account, Message message) { route.isA() ..accountId.equals(account.id) @@ -202,22 +214,9 @@ void main() { testWidgets('at app launch', (tester) async { addTearDown(testBinding.reset); - // Set up a value for `PlatformDispatcher.defaultRouteName` to return, - // for determining the intial route. final account = eg.selfAccount; final message = eg.streamMessage(); - final data = messageFcmMessage(message, account: account); - 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), - }).buildAndroidNotificationUrl(); - addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + setupNotificationDataForLaunch(tester, account, message); // Now start the app. await testBinding.globalStore.add(account, eg.initialSnapshot()); @@ -236,21 +235,9 @@ void main() { 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), - }).buildAndroidNotificationUrl(); - addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + setupNotificationDataForLaunch(tester, accountB, message); await prepare(tester, early: true); check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet From d7d0899db4a9516376e029d0eb66349dfe0e1901 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 28 May 2025 21:24:14 +0530 Subject: [PATCH 169/290] notif ios: Add parser for iOS APNs payload Introduces NotificationOpenPayload.parseIosApnsPayload which can parse the payload that Apple push notification service delivers to the app for displaying a notification. It retrieves the navigation data for the specific message notification. --- lib/notifications/open.dart | 66 +++++++++++++++++++++ test/notifications/open_test.dart | 96 ++++++++++++++++++++++++++++++- 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index 9f4d2afd9c..f47caaacb9 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -100,6 +100,72 @@ class NotificationOpenPayload { required this.narrow, }); + /// Parses the iOS APNs payload and retrieves the information + /// required for navigation. + factory NotificationOpenPayload.parseIosApnsPayload(Map payload) { + if (payload case { + 'zulip': { + 'user_id': final int userId, + 'sender_id': final int senderId, + } && final zulipData, + }) { + final eventType = zulipData['event']; + if (eventType != null && eventType != 'message') { + // On Android, we also receive "remove" notification messages, tagged + // with an `event` field with value 'remove'. As of Zulip Server 10, + // however, these are not yet sent to iOS devices, and we don't have a + // way to handle them even if they were. + // + // The messages we currently do receive, and can handle, are analogous + // to Android notification messages of event type 'message'. On the + // assumption that some future version of the Zulip server will send + // explicit event types in APNs messages, accept messages with that + // `event` value, but no other. + throw const FormatException(); + } + + final realmUrl = switch (zulipData) { + {'realm_url': final String value} => value, + {'realm_uri': final String value} => value, + _ => throw const FormatException(), + }; + + final narrow = switch (zulipData) { + { + 'recipient_type': 'stream', + // TODO(server-5) remove this comment. + // We require 'stream_id' here but that is new from Server 5.0, + // resulting in failure on pre-5.0 servers. + 'stream_id': final int streamId, + 'topic': final String topic, + } => + TopicNarrow(streamId, TopicName(topic)), + + {'recipient_type': 'private', 'pm_users': final String pmUsers} => + DmNarrow( + allRecipientIds: pmUsers + .split(',') + .map((e) => int.parse(e, radix: 10)) + .toList(growable: false) + ..sort(), + selfUserId: userId), + + {'recipient_type': 'private'} => + DmNarrow.withUser(senderId, selfUserId: userId), + + _ => throw const FormatException(), + }; + + return NotificationOpenPayload( + realmUrl: Uri.parse(realmUrl), + userId: userId, + narrow: narrow); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + /// Parses the internal Android notification url, that was created using /// [buildAndroidNotificationUrl], and retrieves the information required /// for navigation. diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index 4ed0c10fd6..46f7a45b5a 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -25,6 +25,50 @@ import '../widgets/message_list_checks.dart'; import '../widgets/page_checks.dart'; import 'display_test.dart'; +Map messageApnsPayload( + Message zulipMessage, { + String? streamName, + Account? account, +}) { + account ??= eg.selfAccount; + return { + "aps": { + "alert": { + "title": "test", + "subtitle": "test", + "body": zulipMessage.content, + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulip.example.cloud", + "realm_id": 4, + "realm_uri": account.realmUrl.toString(), + "realm_url": account.realmUrl.toString(), + "realm_name": "Test", + "user_id": account.userId, + "sender_id": zulipMessage.senderId, + "sender_email": zulipMessage.senderEmail, + "time": zulipMessage.timestamp, + "message_ids": [zulipMessage.id], + ...(switch (zulipMessage) { + StreamMessage(:var streamId, :var topic) => { + "recipient_type": "stream", + "stream_id": streamId, + if (streamName != null) "stream": streamName, + "topic": topic, + }, + DmMessage(allRecipientIds: [_, _, _, ...]) => { + "recipient_type": "private", + "pm_users": zulipMessage.allRecipientIds.join(","), + }, + DmMessage() => {"recipient_type": "private"}, + }), + }, + }; +} + void main() { TestZulipBinding.ensureInitialized(); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; @@ -249,7 +293,7 @@ void main() { }); group('NotificationOpenPayload', () { - test('smoke round-trip', () { + test('android: smoke round-trip', () { // DM narrow var payload = NotificationOpenPayload( realmUrl: Uri.parse('http://chat.example'), @@ -275,6 +319,56 @@ void main() { ..narrow.equals(payload.narrow); }); + group('parseIosApnsPayload', () { + test('smoke one-one DM', () { + final userA = eg.user(userId: 1001); + final userB = eg.user(userId: 1002); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.dmMessage(from: userB, to: [userA]), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..otherRecipientIds.deepEquals([1002])); + }); + + test('smoke group DM', () { + final userA = eg.user(userId: 1001); + final userB = eg.user(userId: 1002); + final userC = eg.user(userId: 1003); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.dmMessage(from: userC, to: [userA, userB]), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..otherRecipientIds.deepEquals([1002, 1003])); + }); + + test('smoke topic message', () { + final userA = eg.user(userId: 1001); + final account = eg.account( + realmUrl: Uri.parse('http://chat.example'), + user: userA); + final payload = messageApnsPayload(eg.streamMessage( + stream: eg.stream(streamId: 1), + topic: 'topic A'), + account: account); + check(NotificationOpenPayload.parseIosApnsPayload(payload)) + ..realmUrl.equals(Uri.parse('http://chat.example')) + ..userId.equals(1001) + ..narrow.which((it) => it.isA() + ..streamId.equals(1) + ..topic.equals(TopicName('topic A'))); + }); + }); + group('buildAndroidNotificationUrl', () { test('smoke DM', () { final url = NotificationOpenPayload( From 3401344fa358209ab68d478b1ae5667bd9191584 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 28 Feb 2025 21:20:42 +0530 Subject: [PATCH 170/290] notif ios: Navigate when app launched from notification Introduces a new Pigeon API file, and adds the corresponding bindings in Swift. Unlike the `pigeon/android_notifications.dart` API this doesn't use the ZulipPlugin hack, as that is only needed when we want the Pigeon functions to be available inside a background isolate (see doc in `zulip_plugin/pubspec.yaml`). Since the notification tap will trigger an app launch first (if not running already) anyway, we can be sure that these new functions won't be running on a Dart background isolate, thus not needing the ZulipPlugin hack. --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 20 +++ ios/Runner/Notifications.g.swift | 235 +++++++++++++++++++++++++++ lib/host/notifications.dart | 1 + lib/host/notifications.g.dart | 146 +++++++++++++++++ lib/model/binding.dart | 14 ++ lib/notifications/open.dart | 87 +++++++++- lib/notifications/receive.dart | 4 + lib/widgets/app.dart | 13 +- pigeon/notifications.dart | 30 ++++ test/model/binding.dart | 21 +++ test/model/store_test.dart | 4 +- test/notifications/open_test.dart | 57 +++++-- 13 files changed, 612 insertions(+), 24 deletions(-) create mode 100644 ios/Runner/Notifications.g.swift create mode 100644 lib/host/notifications.dart create mode 100644 lib/host/notifications.g.dart create mode 100644 pigeon/notifications.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b4928e2220..7df051a142 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34E9F082D776BEB0009AED2 /* Notifications.g.swift */; }; F311C174AF9C005CE4AADD72 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -48,6 +49,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = ""; }; B3AF53A72CA20BD10039801D /* Zulip.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Zulip.xcconfig; path = Flutter/Zulip.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -115,6 +117,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -297,6 +300,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b636303481..33a0fe72cb 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,6 +8,26 @@ import Flutter didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + let controller = window?.rootViewController as! FlutterViewController + + // Retrieve the remote notification payload from launch options; + // this will be null if the launch wasn't triggered by a notification. + let notificationPayload = launchOptions?[.remoteNotification] as? [AnyHashable : Any] + let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) }) + NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } + +private class NotificationHostApiImpl: NotificationHostApi { + private let maybeDataFromLaunch: NotificationDataFromLaunch? + + init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) { + self.maybeDataFromLaunch = maybeDataFromLaunch + } + + func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? { + maybeDataFromLaunch + } +} diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift new file mode 100644 index 0000000000..342953fbad --- /dev/null +++ b/ios/Runner/Notifications.g.swift @@ -0,0 +1,235 @@ +// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +func deepEqualsNotifications(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsNotifications(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsNotifications(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashNotifications(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashNotifications(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashNotifications(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationDataFromLaunch: Hashable { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationDataFromLaunch? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationDataFromLaunch( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } + static func == (lhs: NotificationDataFromLaunch, rhs: NotificationDataFromLaunch) -> Bool { + return deepEqualsNotifications(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNotifications(value: toList(), hasher: &hasher) + } +} + +private class NotificationsPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NotificationsPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NotificationDataFromLaunch { + super.writeByte(129) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NotificationsPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NotificationsPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NotificationsPigeonCodecWriter(data: data) + } +} + +class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + func getNotificationDataFromLaunch() throws -> NotificationDataFromLaunch? +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NotificationHostApiSetup { + static var codec: FlutterStandardMessageCodec { NotificationsPigeonCodec.shared } + /// Sets up an instance of `NotificationHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NotificationHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + let getNotificationDataFromLaunchChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getNotificationDataFromLaunchChannel.setMessageHandler { _, reply in + do { + let result = try api.getNotificationDataFromLaunch() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getNotificationDataFromLaunchChannel.setMessageHandler(nil) + } + } +} diff --git a/lib/host/notifications.dart b/lib/host/notifications.dart new file mode 100644 index 0000000000..6c3e593e2c --- /dev/null +++ b/lib/host/notifications.dart @@ -0,0 +1 @@ +export './notifications.g.dart'; diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart new file mode 100644 index 0000000000..d8448d60b8 --- /dev/null +++ b/lib/host/notifications.g.dart @@ -0,0 +1,146 @@ +// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +class NotificationDataFromLaunch { + NotificationDataFromLaunch({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + Map payload; + + List _toList() { + return [ + payload, + ]; + } + + Object encode() { + return _toList(); } + + static NotificationDataFromLaunch decode(Object result) { + result as List; + return NotificationDataFromLaunch( + payload: (result[0] as Map?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationDataFromLaunch || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NotificationDataFromLaunch) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return NotificationDataFromLaunch.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class NotificationHostApi { + /// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NotificationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + Future getNotificationDataFromLaunch() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as NotificationDataFromLaunch?); + } + } +} diff --git a/lib/model/binding.dart b/lib/model/binding.dart index fb3add46da..7f9b0f6fd1 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus; import '../host/android_notifications.dart'; +import '../host/notifications.dart' as notif_pigeon; import '../log.dart'; import '../widgets/store.dart'; import 'store.dart'; @@ -180,6 +181,9 @@ abstract class ZulipBinding { /// Wraps the [AndroidNotificationHostApi] constructor. AndroidNotificationHostApi get androidNotificationHost; + /// Wraps the [notif_pigeon.NotificationHostApi] class. + NotificationPigeonApi get notificationPigeonApi; + /// Pick files from the media library, via package:file_picker. /// /// This wraps [file_picker.pickFiles]. @@ -324,6 +328,13 @@ class PackageInfo { }); } +class NotificationPigeonApi { + final _hostApi = notif_pigeon.NotificationHostApi(); + + Future getNotificationDataFromLaunch() => + _hostApi.getNotificationDataFromLaunch(); +} + /// A concrete binding for use in the live application. /// /// The global store returned by [getGlobalStore], and consequently by @@ -469,6 +480,9 @@ class LiveZulipBinding extends ZulipBinding { @override AndroidNotificationHostApi get androidNotificationHost => AndroidNotificationHostApi(); + @override + NotificationPigeonApi get notificationPigeonApi => NotificationPigeonApi(); + @override Future pickFiles({ bool allowMultiple = false, diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index f47caaacb9..365bdf4c92 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -6,7 +7,9 @@ import 'package:flutter/widgets.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../host/notifications.dart'; import '../log.dart'; +import '../model/binding.dart'; import '../model/narrow.dart'; import '../widgets/app.dart'; import '../widgets/dialog.dart'; @@ -14,8 +17,75 @@ import '../widgets/message_list.dart'; import '../widgets/page.dart'; import '../widgets/store.dart'; +NotificationPigeonApi get _notifPigeonApi => ZulipBinding.instance.notificationPigeonApi; + /// Responds to the user opening a notification. class NotificationOpenService { + static NotificationOpenService get instance => (_instance ??= NotificationOpenService._()); + static NotificationOpenService? _instance; + + NotificationOpenService._(); + + /// Reset the state of the [NotificationNavigationService], for testing. + static void debugReset() { + _instance = null; + } + + NotificationDataFromLaunch? _notifDataFromLaunch; + + /// A [Future] that completes to signal that the initialization of + /// [NotificationNavigationService] has completed + /// (with either success or failure). + /// + /// Null if [start] hasn't been called. + Future? get initialized => _initializedSignal?.future; + + Completer? _initializedSignal; + + Future start() async { + assert(_initializedSignal == null); + _initializedSignal = Completer(); + try { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); + + case TargetPlatform.android: + // Do nothing; we do notification routing differently on Android. + // TODO migrate Android to use the new Pigeon API. + break; + + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Do nothing; we don't offer notifications on these platforms. + break; + } + } finally { + _initializedSignal!.complete(); + } + } + + /// Provides the route to open if the app was launched through a tap on + /// a notification. + /// + /// Returns null if app launch wasn't triggered by a notification, or if + /// an error occurs while determining the route for the notification. + /// In the latter case an error dialog is also shown. + /// + /// The context argument should be a descendant of the app's main [Navigator]. + AccountRoute? routeForNotificationFromLaunch({required BuildContext context}) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final data = _notifDataFromLaunch; + if (data == null) return null; + assert(debugLog('opened notif: ${jsonEncode(data.payload)}')); + + final notifNavData = _tryParseIosApnsPayload(context, data.payload); + if (notifNavData == null) return null; // TODO(log) + + return routeForNotification(context: context, data: notifNavData); + } /// Provides the route to open by parsing the notification payload. /// @@ -27,8 +97,6 @@ class NotificationOpenService { required BuildContext context, required NotificationOpenPayload data, }) { - assert(defaultTargetPlatform == TargetPlatform.android); - final globalStore = GlobalStoreWidget.of(context); final account = globalStore.accounts.firstWhereOrNull( @@ -71,6 +139,21 @@ class NotificationOpenService { unawaited(navigator.push(route)); } + static NotificationOpenPayload? _tryParseIosApnsPayload( + BuildContext context, + Map payload, + ) { + try { + return NotificationOpenPayload.parseIosApnsPayload(payload); + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } + static NotificationOpenPayload? tryParseAndroidNotificationUrl({ required BuildContext context, required Uri url, diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index d60469ff30..212b0f5f0d 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -8,6 +8,7 @@ import '../firebase_options.dart'; import '../log.dart'; import '../model/binding.dart'; import 'display.dart'; +import 'open.dart'; @pragma('vm:entry-point') class NotificationService { @@ -24,6 +25,7 @@ class NotificationService { instance.token.dispose(); _instance = null; assert(debugBackgroundIsolateIsLive = true); + NotificationOpenService.debugReset(); } /// Whether a background isolate should initialize [LiveZulipBinding]. @@ -77,6 +79,8 @@ class NotificationService { await _getFcmToken(); case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission + await NotificationOpenService.instance.start(); + await ZulipBinding.instance.firebaseInitializeApp( options: kFirebaseOptionsIos); diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 96c546bd3f..b1aa763ac8 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -168,6 +168,12 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } + AccountRoute? _initialRouteIos(BuildContext context) { + return NotificationOpenService.instance + .routeForNotificationFromLaunch(context: context); + } + + // TODO migrate Android's notification navigation to use the new Pigeon API. AccountRoute? _initialRouteAndroid( BuildContext context, String initialRoute, @@ -190,10 +196,12 @@ class _ZulipAppState extends State with WidgetsBindingObserver { 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. + // called and its context should have the required ancestors. final context = ZulipApp.navigatorKey.currentContext!; - final route = _initialRouteAndroid(context, initialRoute); + final route = defaultTargetPlatform == TargetPlatform.iOS + ? _initialRouteIos(context) + : _initialRouteAndroid(context, initialRoute); if (route != null) { return [ HomePage.buildRoute(accountId: route.accountId), @@ -228,6 +236,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return GlobalStoreWidget( + blockingFuture: NotificationOpenService.instance.initialized, child: Builder(builder: (context) { return MaterialApp( onGenerateTitle: (BuildContext context) { diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart new file mode 100644 index 0000000000..efea52d9a6 --- /dev/null +++ b/pigeon/notifications.dart @@ -0,0 +1,30 @@ +import 'package:pigeon/pigeon.dart'; + +// To rebuild this pigeon's output after editing this file, +// run `tools/check pigeon --fix`. +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/notifications.g.dart', + swiftOut: 'ios/Runner/Notifications.g.swift', +)) + +class NotificationDataFromLaunch { + const NotificationDataFromLaunch({required this.payload}); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + final Map payload; +} + +@HostApi() +abstract class NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + NotificationDataFromLaunch? getNotificationDataFromLaunch(); +} diff --git a/test/model/binding.dart b/test/model/binding.dart index 6b4de26608..afeed2f266 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:zulip/host/android_notifications.dart'; +import 'package:zulip/host/notifications.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; @@ -311,6 +312,7 @@ class TestZulipBinding extends ZulipBinding { void _resetNotifications() { _androidNotificationHostApi = null; + _notificationPigeonApi = null; } @override @@ -318,6 +320,11 @@ class TestZulipBinding extends ZulipBinding { (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); FakeAndroidNotificationHostApi? _androidNotificationHostApi; + @override + FakeNotificationPigeonApi get notificationPigeonApi => + (_notificationPigeonApi ??= FakeNotificationPigeonApi()); + FakeNotificationPigeonApi? _notificationPigeonApi; + /// The value that `ZulipBinding.instance.pickFiles()` should return. /// /// See also [takePickFilesCalls]. @@ -754,6 +761,20 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } } +class FakeNotificationPigeonApi implements NotificationPigeonApi { + NotificationDataFromLaunch? _notificationDataFromLaunch; + + /// Populates the notification data for launch to be returned + /// by [getNotificationDataFromLaunch]. + void setNotificationDataFromLaunch(NotificationDataFromLaunch? data) { + _notificationDataFromLaunch = data; + } + + @override + Future getNotificationDataFromLaunch() async => + _notificationDataFromLaunch; +} + typedef AndroidNotificationHostApiNotifyCall = ({ String? tag, int id, diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 0b303b53e2..c3aadb1171 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -1293,8 +1293,8 @@ void main() { // (This is probably the common case.) addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; - addTearDown(NotificationService.debugReset); testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); + addTearDown(NotificationService.debugReset); await NotificationService.instance.start(); // On store startup, send the token. @@ -1321,8 +1321,8 @@ void main() { // request for the token is still pending. addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; - addTearDown(NotificationService.debugReset); testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); + addTearDown(NotificationService.debugReset); final startFuture = NotificationService.instance.start(); // TODO this test is a bit brittle in its interaction with asynchrony; diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index 46f7a45b5a..db86c71abc 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/notifications.dart'; +import 'package:zulip/host/notifications.dart'; import 'package:zulip/model/database.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; @@ -133,18 +135,37 @@ void main() { } Future openNotification(WidgetTester tester, Account account, Message message) async { - final intentDataUrl = androidNotificationUrlForMessage(account, message); - unawaited( - WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); - await tester.idle(); // let navigateForNotification find navigator + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final intentDataUrl = androidNotificationUrlForMessage(account, message); + unawaited( + WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); + await tester.idle(); // let navigateForNotification find navigator + + default: + throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); + } } void setupNotificationDataForLaunch(WidgetTester tester, Account account, Message message) { - // Set up a value for `PlatformDispatcher.defaultRouteName` to return, - // for determining the initial route. - final intentDataUrl = androidNotificationUrlForMessage(account, message); - addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + // Set up a value for `PlatformDispatcher.defaultRouteName` to return, + // for determining the initial route. + final intentDataUrl = androidNotificationUrlForMessage(account, message); + addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + + case TargetPlatform.iOS: + // Set up a value to return for + // `notificationPigeonApi.getNotificationDataFromLaunch`. + final payload = messageApnsPayload(message, account: account); + testBinding.notificationPigeonApi.setNotificationDataFromLaunch( + NotificationDataFromLaunch(payload: payload)); + + default: + throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); + } } void matchesNavigation(Subject> route, Account account, Message message) { @@ -166,7 +187,7 @@ void main() { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await prepare(tester); await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android})); testWidgets('direct message', (tester) async { addTearDown(testBinding.reset); @@ -174,7 +195,7 @@ void main() { await prepare(tester); await checkOpenNotification(tester, eg.selfAccount, eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android})); testWidgets('account queried by realmUrl origin component', (tester) async { addTearDown(testBinding.reset); @@ -189,7 +210,7 @@ void main() { await checkOpenNotification(tester, eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), eg.streamMessage()); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android})); testWidgets('no accounts', (tester) async { await prepare(tester, withAccount: false); @@ -199,7 +220,7 @@ void main() { await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android})); testWidgets('mismatching account', (tester) async { addTearDown(testBinding.reset); @@ -211,7 +232,7 @@ void main() { await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android})); testWidgets('find account among several', (tester) async { addTearDown(testBinding.reset); @@ -234,7 +255,7 @@ void main() { await checkOpenNotification(tester, accounts[1], eg.streamMessage()); await checkOpenNotification(tester, accounts[2], eg.streamMessage()); await checkOpenNotification(tester, accounts[3], eg.streamMessage()); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android})); testWidgets('wait for app to become ready', (tester) async { addTearDown(testBinding.reset); @@ -254,7 +275,7 @@ void main() { takeStartingRoutes(); // … and then the one the notification leads to. matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android})); testWidgets('at app launch', (tester) async { addTearDown(testBinding.reset); @@ -271,7 +292,7 @@ void main() { await tester.pump(); takeStartingRoutes(); matchesNavigation(check(pushedRoutes).single, account, message); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('uses associated account as initial account; if initial route', (tester) async { addTearDown(testBinding.reset); @@ -289,7 +310,7 @@ void main() { await tester.pump(); takeStartingRoutes(account: accountB); matchesNavigation(check(pushedRoutes).single, accountB, message); - }); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); }); group('NotificationOpenPayload', () { From 6dce76419159277fc765a926252f21db32f4f4a7 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 3 Mar 2025 20:57:00 +0530 Subject: [PATCH 171/290] notif ios: Navigate when app running but in background --- ios/Runner/AppDelegate.swift | 33 ++++++++++ ios/Runner/Notifications.g.swift | 100 ++++++++++++++++++++++++++++++ lib/host/notifications.g.dart | 64 +++++++++++++++++++ lib/model/binding.dart | 6 ++ lib/notifications/open.dart | 23 +++++++ pigeon/notifications.dart | 23 +++++++ test/model/binding.dart | 12 ++++ test/notifications/open_test.dart | 20 +++--- 8 files changed, 274 insertions(+), 7 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 33a0fe72cb..eefed07cd6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,6 +3,8 @@ import Flutter @main @objc class AppDelegate: FlutterAppDelegate { + private var notificationTapEventListener: NotificationTapEventListener? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -16,8 +18,25 @@ import Flutter let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) }) NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api) + notificationTapEventListener = NotificationTapEventListener() + NotificationTapEventsStreamHandler.register(with: controller.binaryMessenger, streamHandler: notificationTapEventListener!) + + UNUserNotificationCenter.current().delegate = self + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier { + let userInfo = response.notification.request.content.userInfo + notificationTapEventListener!.onNotificationTapEvent(payload: userInfo) + } + completionHandler() + } } private class NotificationHostApiImpl: NotificationHostApi { @@ -31,3 +50,17 @@ private class NotificationHostApiImpl: NotificationHostApi { maybeDataFromLaunch } } + +// Adapted from Pigeon's Swift example for @EventChannelApi: +// https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74 +class NotificationTapEventListener: NotificationTapEventsStreamHandler { + var eventSink: PigeonEventSink? + + override func onListen(withArguments arguments: Any?, sink: PigeonEventSink) { + eventSink = sink + } + + func onNotificationTapEvent(payload: [AnyHashable : Any]) { + eventSink?.success(NotificationTapEvent(payload: payload)) + } +} diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift index 342953fbad..40db818d33 100644 --- a/ios/Runner/Notifications.g.swift +++ b/ios/Runner/Notifications.g.swift @@ -157,11 +157,42 @@ struct NotificationDataFromLaunch: Hashable { } } +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationTapEvent: Hashable { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationTapEvent( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } + static func == (lhs: NotificationTapEvent, rhs: NotificationTapEvent) -> Bool { + return deepEqualsNotifications(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNotifications(value: toList(), hasher: &hasher) + } +} + private class NotificationsPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { case 129: return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?]) + case 130: + return NotificationTapEvent.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -173,6 +204,9 @@ private class NotificationsPigeonCodecWriter: FlutterStandardWriter { if let value = value as? NotificationDataFromLaunch { super.writeByte(129) super.writeValue(value.toList()) + } else if let value = value as? NotificationTapEvent { + super.writeByte(130) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -193,6 +227,8 @@ class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter()) } +var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter()); + /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol NotificationHostApi { /// Retrieves notification data if the app was launched by tapping on a notification. @@ -233,3 +269,67 @@ class NotificationHostApiSetup { } } } + +private class PigeonStreamHandler: NSObject, FlutterStreamHandler { + private let wrapper: PigeonEventChannelWrapper + private var pigeonSink: PigeonEventSink? = nil + + init(wrapper: PigeonEventChannelWrapper) { + self.wrapper = wrapper + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + pigeonSink = PigeonEventSink(events) + wrapper.onListen(withArguments: arguments, sink: pigeonSink!) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + pigeonSink = nil + wrapper.onCancel(withArguments: arguments) + return nil + } +} + +class PigeonEventChannelWrapper { + func onListen(withArguments arguments: Any?, sink: PigeonEventSink) {} + func onCancel(withArguments arguments: Any?) {} +} + +class PigeonEventSink { + private let sink: FlutterEventSink + + init(_ sink: @escaping FlutterEventSink) { + self.sink = sink + } + + func success(_ value: ReturnType) { + sink(value) + } + + func error(code: String, message: String?, details: Any?) { + sink(FlutterError(code: code, message: message, details: details)) + } + + func endOfStream() { + sink(FlutterEndOfEventStream) + } + +} + +class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper { + static func register(with messenger: FlutterBinaryMessenger, + instanceName: String = "", + streamHandler: NotificationTapEventsStreamHandler) { + var channelName = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents" + if !instanceName.isEmpty { + channelName += ".\(instanceName)" + } + let internalStreamHandler = PigeonStreamHandler(wrapper: streamHandler) + let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec) + channel.setStreamHandler(internalStreamHandler) + } +} + diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart index d8448d60b8..a83b67c804 100644 --- a/lib/host/notifications.g.dart +++ b/lib/host/notifications.g.dart @@ -74,6 +74,51 @@ class NotificationDataFromLaunch { ; } +class NotificationTapEvent { + NotificationTapEvent({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + Map payload; + + List _toList() { + return [ + payload, + ]; + } + + Object encode() { + return _toList(); } + + static NotificationTapEvent decode(Object result) { + result as List; + return NotificationTapEvent( + payload: (result[0] as Map?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationTapEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -85,6 +130,9 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is NotificationDataFromLaunch) { buffer.putUint8(129); writeValue(buffer, value.encode()); + } else if (value is NotificationTapEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -95,12 +143,16 @@ class _PigeonCodec extends StandardMessageCodec { switch (type) { case 129: return NotificationDataFromLaunch.decode(readValue(buffer)!); + case 130: + return NotificationTapEvent.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } } } +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec()); + class NotificationHostApi { /// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default @@ -144,3 +196,15 @@ class NotificationHostApi { } } } + +Stream notificationTapEvents( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel notificationTapEventsChannel = + EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec); + return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as NotificationTapEvent; + }); +} + diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 7f9b0f6fd1..4d1a0adaac 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -328,11 +328,17 @@ class PackageInfo { }); } +// Pigeon generates methods under `@EventChannelApi` annotated classes +// in global scope of the generated file. This is a helper class to +// namespace the notification related Pigeon API under a single class. class NotificationPigeonApi { final _hostApi = notif_pigeon.NotificationHostApi(); Future getNotificationDataFromLaunch() => _hostApi.getNotificationDataFromLaunch(); + + Stream notificationTapEventsStream() => + notif_pigeon.notificationTapEvents(); } /// A concrete binding for use in the live application. diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index 365bdf4c92..2eb281473c 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -49,6 +49,8 @@ class NotificationOpenService { switch (defaultTargetPlatform) { case TargetPlatform.iOS: _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); + _notifPigeonApi.notificationTapEventsStream() + .listen(_navigateForNotification); case TargetPlatform.android: // Do nothing; we do notification routing differently on Android. @@ -116,6 +118,27 @@ class NotificationOpenService { narrow: data.narrow); } + /// Navigates to the [MessageListPage] of the specific conversation + /// for the provided payload that was attached while creating the + /// notification. + static Future _navigateForNotification(NotificationTapEvent event) async { + assert(defaultTargetPlatform == TargetPlatform.iOS); + assert(debugLog('opened notif: ${jsonEncode(event.payload)}')); + + 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 notifNavData = _tryParseIosApnsPayload(context, event.payload); + if (notifNavData == null) return; // TODO(log) + final route = routeForNotification(context: context, data: notifNavData); + if (route == null) return; // TODO(log) + + // TODO(nav): Better interact with existing nav stack on notif open + unawaited(navigator.push(route)); + } + /// Navigates to the [MessageListPage] of the specific conversation /// given the `zulip://notification/…` Android intent data URL, /// generated with [NotificationOpenPayload.buildAndroidNotificationUrl] diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index efea52d9a6..66c1bd2e71 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -17,6 +17,16 @@ class NotificationDataFromLaunch { final Map payload; } +class NotificationTapEvent { + const NotificationTapEvent({required this.payload}); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + final Map payload; +} + @HostApi() abstract class NotificationHostApi { /// Retrieves notification data if the app was launched by tapping on a notification. @@ -28,3 +38,16 @@ abstract class NotificationHostApi { /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification NotificationDataFromLaunch? getNotificationDataFromLaunch(); } + +@EventChannelApi() +abstract class NotificationEventChannelApi { + /// An event stream that emits a notification payload when the app + /// encounters a notification tap, while the app is running. + /// + /// Emits an event when + /// `userNotificationCenter(_:didReceive:withCompletionHandler:)` gets + /// called, indicating that the user has tapped on a notification. The + /// emitted payload will be the raw APNs data dictionary from the + /// `UNNotificationResponse` passed to that method. + NotificationTapEvent notificationTapEvents(); +} diff --git a/test/model/binding.dart b/test/model/binding.dart index afeed2f266..839242c1ce 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -773,6 +773,18 @@ class FakeNotificationPigeonApi implements NotificationPigeonApi { @override Future getNotificationDataFromLaunch() async => _notificationDataFromLaunch; + + StreamController? _notificationTapEventsStreamController; + + void addNotificationTapEvent(NotificationTapEvent event) { + _notificationTapEventsStreamController!.add(event); + } + + @override + Stream notificationTapEventsStream() { + _notificationTapEventsStreamController ??= StreamController(); + return _notificationTapEventsStreamController!.stream; + } } typedef AndroidNotificationHostApiNotifyCall = ({ diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index db86c71abc..a2c14ca20a 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -142,6 +142,12 @@ void main() { WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); await tester.idle(); // let navigateForNotification find navigator + case TargetPlatform.iOS: + final payload = messageApnsPayload(message, account: account); + testBinding.notificationPigeonApi.addNotificationTapEvent( + NotificationTapEvent(payload: payload)); + await tester.idle(); // let navigateForNotification find navigator + default: throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); } @@ -187,7 +193,7 @@ void main() { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await prepare(tester); await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); - }, variant: const TargetPlatformVariant({TargetPlatform.android})); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('direct message', (tester) async { addTearDown(testBinding.reset); @@ -195,7 +201,7 @@ void main() { await prepare(tester); await checkOpenNotification(tester, eg.selfAccount, eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); - }, variant: const TargetPlatformVariant({TargetPlatform.android})); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('account queried by realmUrl origin component', (tester) async { addTearDown(testBinding.reset); @@ -210,7 +216,7 @@ void main() { await checkOpenNotification(tester, eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), eg.streamMessage()); - }, variant: const TargetPlatformVariant({TargetPlatform.android})); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('no accounts', (tester) async { await prepare(tester, withAccount: false); @@ -220,7 +226,7 @@ void main() { await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); - }, variant: const TargetPlatformVariant({TargetPlatform.android})); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('mismatching account', (tester) async { addTearDown(testBinding.reset); @@ -232,7 +238,7 @@ void main() { await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); - }, variant: const TargetPlatformVariant({TargetPlatform.android})); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('find account among several', (tester) async { addTearDown(testBinding.reset); @@ -255,7 +261,7 @@ void main() { await checkOpenNotification(tester, accounts[1], eg.streamMessage()); await checkOpenNotification(tester, accounts[2], eg.streamMessage()); await checkOpenNotification(tester, accounts[3], eg.streamMessage()); - }, variant: const TargetPlatformVariant({TargetPlatform.android})); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('wait for app to become ready', (tester) async { addTearDown(testBinding.reset); @@ -275,7 +281,7 @@ void main() { takeStartingRoutes(); // … and then the one the notification leads to. matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); - }, variant: const TargetPlatformVariant({TargetPlatform.android})); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('at app launch', (tester) async { addTearDown(testBinding.reset); From b7646c71850762640bcfd0f0656d75e83e2a43ef Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 12 Jun 2025 23:57:48 +0530 Subject: [PATCH 172/290] deps: Upgrade Flutter to 3.33.0-1.0.pre.465 And update Flutter's supporting libraries to match. --- pubspec.lock | 20 ++++++++++---------- pubspec.yaml | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 4784adf19a..660cd05383 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1079,26 +1079,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.25.15" + version: "1.26.2" test_api: dependency: "direct dev" description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.8" + version: "0.6.11" timing: dependency: transitive description: @@ -1191,10 +1191,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" video_player: dependency: "direct main" description: @@ -1355,5 +1355,5 @@ packages: source: path version: "0.0.1" sdks: - dart: ">=3.9.0-114.0.dev <4.0.0" - flutter: ">=3.33.0-1.0.pre.44" + dart: ">=3.9.0-220.0.dev <4.0.0" + flutter: ">=3.33.0-1.0.pre.465" diff --git a/pubspec.yaml b/pubspec.yaml index 68ad256356..bcc202d75b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,8 +14,8 @@ environment: # We use a recent version of Flutter from its main channel, and # the corresponding recent version of the Dart SDK. # Feel free to update these regularly; see README.md for instructions. - sdk: '>=3.9.0-114.0.dev <4.0.0' - flutter: '>=3.33.0-1.0.pre.44' # 358b0726882869cd917e1e2dc07b6c298e6c2992 + sdk: '>=3.9.0-220.0.dev <4.0.0' + flutter: '>=3.33.0-1.0.pre.465' # ee089d09b21ec3ccc20d179c5be100d2a9d9f866 # To update dependencies, see instructions in README.md. dependencies: From 9f18a167475215489fd66cc7b22124a7f7312061 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 00:12:11 +0530 Subject: [PATCH 173/290] l10n: Remove use of deprecated "synthetic-package" option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this change, `flutter run` prints the following warning message: /…/zulip-flutter/l10n.yaml: The argument "synthetic-package" no longer has any effect and should be removed. See http://flutter.dev/to/flutter-gen-deprecation --- l10n.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/l10n.yaml b/l10n.yaml index 6d15a20096..563219f948 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,7 +1,6 @@ # Docs on this config file: # https://docs.flutter.dev/ui/accessibility-and-localization/internationalization#configuring-the-l10nyaml-file -synthetic-package: false arb-dir: assets/l10n output-dir: lib/generated/l10n template-arb-file: app_en.arb From 8f2b647028e42445a5877609b55a6a49043b03f9 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 00:12:59 +0530 Subject: [PATCH 174/290] deps: Update CocoaPods pods (tools/upgrade pod) --- ios/Podfile.lock | 26 +++++++++++++------------- macos/Podfile.lock | 18 +++++++++--------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1bd78a4b7f..5dfc966550 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -112,23 +112,23 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.4.0) - - SDWebImage (5.21.0): - - SDWebImage/Core (= 5.21.0) - - SDWebImage/Core (5.21.0) + - SDWebImage (5.21.1): + - SDWebImage/Core (= 5.21.1) + - SDWebImage/Core (5.21.1) - share_plus (0.0.1): - Flutter - - sqlite3 (3.49.1): - - sqlite3/common (= 3.49.1) - - sqlite3/common (3.49.1) - - sqlite3/dbstatvtab (3.49.1): + - sqlite3 (3.49.2): + - sqlite3/common (= 3.49.2) + - sqlite3/common (3.49.2) + - sqlite3/dbstatvtab (3.49.2): - sqlite3/common - - sqlite3/fts5 (3.49.1): + - sqlite3/fts5 (3.49.2): - sqlite3/common - - sqlite3/math (3.49.1): + - sqlite3/math (3.49.2): - sqlite3/common - - sqlite3/perf-threadsafe (3.49.1): + - sqlite3/perf-threadsafe (3.49.2): - sqlite3/common - - sqlite3/rtree (3.49.1): + - sqlite3/rtree (3.49.2): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter @@ -236,9 +236,9 @@ SPEC CHECKSUMS: package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 + SDWebImage: f29024626962457f3470184232766516dee8dfea share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 + sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d diff --git a/macos/Podfile.lock b/macos/Podfile.lock index bb5fd1a927..238d939668 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -81,18 +81,18 @@ PODS: - PromisesObjC (2.4.0) - share_plus (0.0.1): - FlutterMacOS - - sqlite3 (3.49.1): - - sqlite3/common (= 3.49.1) - - sqlite3/common (3.49.1) - - sqlite3/dbstatvtab (3.49.1): + - sqlite3 (3.49.2): + - sqlite3/common (= 3.49.2) + - sqlite3/common (3.49.2) + - sqlite3/dbstatvtab (3.49.2): - sqlite3/common - - sqlite3/fts5 (3.49.1): + - sqlite3/fts5 (3.49.2): - sqlite3/common - - sqlite3/math (3.49.1): + - sqlite3/math (3.49.2): - sqlite3/common - - sqlite3/perf-threadsafe (3.49.1): + - sqlite3/perf-threadsafe (3.49.2): - sqlite3/common - - sqlite3/rtree (3.49.1): + - sqlite3/rtree (3.49.2): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter @@ -190,7 +190,7 @@ SPEC CHECKSUMS: path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 + sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b From 44956c65e2e0a3431eccebaad8c83fd481256292 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 00:35:16 +0530 Subject: [PATCH 175/290] deps: Update flutter_lints to 6.0.0, from 5.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changelog: https://pub.dev/packages/flutter_lints/changelog#600 Also includes changes to fix lint failures for the new 'unnecessary_underscores' lint, probably to encourage using wildcard variable `_`: https://dart.dev/language/variables#wildcard-variables Without this change `flutter analyze` reports the following: info • Unnecessary use of multiple underscores • lib/widgets/autocomplete.dart:133:40 • unnecessary_underscores info • Unnecessary use of multiple underscores • lib/widgets/autocomplete.dart:156:38 • unnecessary_underscores info • Unnecessary use of multiple underscores • lib/widgets/autocomplete.dart:156:42 • unnecessary_underscores info • Unnecessary use of multiple underscores • lib/widgets/emoji_reaction.dart:333:34 • unnecessary_underscores --- lib/widgets/autocomplete.dart | 4 ++-- lib/widgets/emoji_reaction.dart | 2 +- pubspec.lock | 8 ++++---- pubspec.yaml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index a1956295eb..676b30a45c 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -130,7 +130,7 @@ class _AutocompleteFieldState) - fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context), + fieldViewBuilder: (context, _, _, _) => widget.fieldViewBuilder(context), ); } } diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 0f6d490a97..1f5dc557ec 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -330,7 +330,7 @@ class _ImageEmoji extends StatelessWidget { // Unicode and text emoji get scaled; it would look weird if image emoji didn't. textScaler: _squareEmojiScalerClamped(context), emojiDisplay: emojiDisplay, - errorBuilder: (context, _, __) => _TextEmoji( + errorBuilder: (context, _, _) => _TextEmoji( emojiDisplay: TextEmojiDisplay(emojiName: emojiName), selected: selected), ); } diff --git a/pubspec.lock b/pubspec.lock index 660cd05383..f965886e6f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -441,10 +441,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -682,10 +682,10 @@ packages: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.0.0" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bcc202d75b..1f0d12f259 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,7 +98,7 @@ dev_dependencies: drift_dev: ^2.5.2 fake_async: ^1.3.1 flutter_checks: ^0.1.2 - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 ini: ^2.1.0 json_serializable: ^6.5.4 legacy_checks: ^0.1.0 From cc47c89d969bfaf3338294fa5992683c66804994 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 00:47:33 +0530 Subject: [PATCH 176/290] deps: Upgrade firebase_core, firebase_messaging to latest This commit is result of following commands: flutter pub upgrade --major-versions firebase_messaging firebase_core pod update --project-directory=ios/ pod update --project-directory=macos/ Changelogs: https://pub.dev/packages/firebase_core/changelog#3140 https://pub.dev/packages/firebase_messaging/changelog#1527 Notable change is Firebase iOS SDK bump to 11.13.0, from 11.10.0, changelog for that is at: https://firebase.google.com/support/release-notes/ios No changes there for FCM (the only component we use). --- ios/Podfile.lock | 62 ++++++++++++++++++++++---------------------- macos/Podfile.lock | 64 +++++++++++++++++++++++----------------------- pubspec.lock | 24 ++++++++--------- 3 files changed, 75 insertions(+), 75 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5dfc966550..7ab5f37048 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -37,37 +37,37 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase/CoreOnly (11.10.0): - - FirebaseCore (~> 11.10.0) - - Firebase/Messaging (11.10.0): + - Firebase/CoreOnly (11.13.0): + - FirebaseCore (~> 11.13.0) + - Firebase/Messaging (11.13.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.10.0) - - firebase_core (3.13.0): - - Firebase/CoreOnly (= 11.10.0) + - FirebaseMessaging (~> 11.13.0) + - firebase_core (3.14.0): + - Firebase/CoreOnly (= 11.13.0) - Flutter - - firebase_messaging (15.2.5): - - Firebase/Messaging (= 11.10.0) + - firebase_messaging (15.2.7): + - Firebase/Messaging (= 11.13.0) - firebase_core - Flutter - - FirebaseCore (11.10.0): - - FirebaseCoreInternal (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Logger (~> 8.0) - - FirebaseCoreInternal (11.10.0): - - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseInstallations (11.10.0): - - FirebaseCore (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseCore (11.13.0): + - FirebaseCoreInternal (~> 11.13.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.13.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.13.0): + - FirebaseCore (~> 11.13.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.10.0): - - FirebaseCore (~> 11.10.0) + - FirebaseMessaging (11.13.0): + - FirebaseCore (~> 11.13.0) - FirebaseInstallations (~> 11.0) - GoogleDataTransport (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Reachability (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - Flutter (1.0.0) - GoogleDataTransport (10.1.0): @@ -220,13 +220,13 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_core: 2d4534e7b489907dcede540c835b48981d890943 - firebase_messaging: 75bc93a4df25faccad67f6662ae872ac9ae69b64 - FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 - FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 - FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 - FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 + Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327 + firebase_core: 700bac7ed92bb754fd70fbf01d72b36ecdd6d450 + firebase_messaging: 860c017fcfbb5e27c163062d1d3135388f3ef954 + FirebaseCore: c692c7f1c75305ab6aff2b367f25e11d73aa8bd0 + FirebaseCoreInternal: 29d7b3af4aaf0b8f3ed20b568c13df399b06f68c + FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 + FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 238d939668..33a9c67b5c 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -7,38 +7,38 @@ PODS: - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS - - Firebase/CoreOnly (11.10.0): - - FirebaseCore (~> 11.10.0) - - Firebase/Messaging (11.10.0): + - Firebase/CoreOnly (11.13.0): + - FirebaseCore (~> 11.13.0) + - Firebase/Messaging (11.13.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.10.0) - - firebase_core (3.13.0): - - Firebase/CoreOnly (~> 11.10.0) + - FirebaseMessaging (~> 11.13.0) + - firebase_core (3.14.0): + - Firebase/CoreOnly (~> 11.13.0) - FlutterMacOS - - firebase_messaging (15.2.5): - - Firebase/CoreOnly (~> 11.10.0) - - Firebase/Messaging (~> 11.10.0) + - firebase_messaging (15.2.7): + - Firebase/CoreOnly (~> 11.13.0) + - Firebase/Messaging (~> 11.13.0) - firebase_core - FlutterMacOS - - FirebaseCore (11.10.0): - - FirebaseCoreInternal (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Logger (~> 8.0) - - FirebaseCoreInternal (11.10.0): - - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseInstallations (11.10.0): - - FirebaseCore (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseCore (11.13.0): + - FirebaseCoreInternal (~> 11.13.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.13.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.13.0): + - FirebaseCore (~> 11.13.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.10.0): - - FirebaseCore (~> 11.10.0) + - FirebaseMessaging (11.13.0): + - FirebaseCore (~> 11.13.0) - FirebaseInstallations (~> 11.0) - GoogleDataTransport (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Reachability (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - FlutterMacOS (1.0.0) - GoogleDataTransport (10.1.0): @@ -175,13 +175,13 @@ SPEC CHECKSUMS: device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 - Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_core: efd50ad8177dc489af1b9163a560359cf1b30597 - firebase_messaging: acf2566068a55d7eb8cddfee5b094754070a5b88 - FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 - FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 - FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 - FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 + Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327 + firebase_core: 1095fcf33161d99bc34aa10f7c0d89414a208d15 + firebase_messaging: 6417056ffb85141607618ddfef9fec9f3caab3ea + FirebaseCore: c692c7f1c75305ab6aff2b367f25e11d73aa8bd0 + FirebaseCoreInternal: 29d7b3af4aaf0b8f3ed20b568c13df399b06f68c + FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 + FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 diff --git a/pubspec.lock b/pubspec.lock index f965886e6f..15cd95309f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: de9ecbb3ddafd446095f7e833c853aff2fa1682b017921fe63a833f9d6f0e422 + sha256: dda4fd7909a732a014239009aa52537b136f8ce568de23c212587097887e2307 url: "https://pub.dev" source: hosted - version: "1.3.54" + version: "1.3.56" analyzer: dependency: transitive description: @@ -358,10 +358,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "017d17d9915670e6117497e640b2859e0b868026ea36bf3a57feb28c3b97debe" + sha256: "420d9111dcf095341f1ea8fdce926eef750cf7b9745d21f38000de780c94f608" url: "https://pub.dev" source: hosted - version: "3.13.0" + version: "3.14.0" firebase_core_platform_interface: dependency: transitive description: @@ -374,34 +374,34 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: "129a34d1e0fb62e2b488d988a1fc26cc15636357e50944ffee2862efe8929b23" + sha256: ddd72baa6f727e5b23f32d9af23d7d453d67946f380bd9c21daf474ee0f7326e url: "https://pub.dev" source: hosted - version: "2.22.0" + version: "2.23.0" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "5f8918848ee0c8eb172fc7698619b2bcd7dda9ade8b93522c6297dd8f9178356" + sha256: "758461f67b96aa5ad27625aaae39882fd6d1961b1c7e005301f9a74b6336100b" url: "https://pub.dev" source: hosted - version: "15.2.5" + version: "15.2.7" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "0bbea00680249595fc896e7313a2bd90bd55be6e0abbe8b9a39d81b6b306acb6" + sha256: "614db1b0df0f53e541e41cc182b6d7ede5763c400f6ba232a5f8d0e1b5e5de32" url: "https://pub.dev" source: hosted - version: "4.6.5" + version: "4.6.7" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: ffb392ce2a7e8439cd0a9a80e3c702194e73c927e5c7b4f0adf6faa00b245b17 + sha256: b5fbbcdd3e0e7f3fde72b0c119410f22737638fed5fc428b54bba06bc1455d81 url: "https://pub.dev" source: hosted - version: "3.10.5" + version: "3.10.7" fixnum: dependency: transitive description: From 2566f02e7332eb55c0b1c31b42cb9c6c50a6bc4a Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 01:30:48 +0530 Subject: [PATCH 177/290] deps: Update dart_style to 3.1.0, from 3.0.1 Changelog: https://pub.dev/packages/dart_style/changelog#310 This commit was produced by editing pubspec.yaml, then: $ flutter pub get $ ./tools/check --all-files --fix build_runner l10n drift pigeon --- lib/api/model/events.g.dart | 140 ++++++------ lib/api/model/initial_snapshot.g.dart | 116 +++++----- lib/api/model/model.g.dart | 16 +- lib/api/route/channels.g.dart | 7 +- lib/api/route/events.g.dart | 7 +- lib/api/route/messages.g.dart | 7 +- lib/model/database.g.dart | 303 +++++++++++--------------- pubspec.lock | 4 +- test/model/schemas/schema_v1.dart | 96 ++++---- test/model/schemas/schema_v2.dart | 115 +++++----- test/model/schemas/schema_v3.dart | 129 +++++------ test/model/schemas/schema_v4.dart | 150 ++++++------- test/model/schemas/schema_v5.dart | 150 ++++++------- test/model/schemas/schema_v6.dart | 168 +++++++------- test/model/schemas/schema_v7.dart | 189 +++++++--------- 15 files changed, 703 insertions(+), 894 deletions(-) diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 94fe288150..ef8a214566 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -29,10 +29,9 @@ Map _$RealmEmojiUpdateEventToJson( AlertWordsEvent _$AlertWordsEventFromJson(Map json) => AlertWordsEvent( id: (json['id'] as num).toInt(), - alertWords: - (json['alert_words'] as List) - .map((e) => e as String) - .toList(), + alertWords: (json['alert_words'] as List) + .map((e) => e as String) + .toList(), ); Map _$AlertWordsEventToJson(AlertWordsEvent instance) => @@ -74,10 +73,9 @@ CustomProfileFieldsEvent _$CustomProfileFieldsEventFromJson( Map json, ) => CustomProfileFieldsEvent( id: (json['id'] as num).toInt(), - fields: - (json['fields'] as List) - .map((e) => CustomProfileField.fromJson(e as Map)) - .toList(), + fields: (json['fields'] as List) + .map((e) => CustomProfileField.fromJson(e as Map)) + .toList(), ); Map _$CustomProfileFieldsEventToJson( @@ -122,8 +120,8 @@ RealmUserUpdateEvent _$RealmUserUpdateEventFromJson( Map json, ) => RealmUserUpdateEvent( id: (json['id'] as num).toInt(), - userId: - (RealmUserUpdateEvent._readFromPerson(json, 'user_id') as num).toInt(), + userId: (RealmUserUpdateEvent._readFromPerson(json, 'user_id') as num) + .toInt(), fullName: RealmUserUpdateEvent._readFromPerson(json, 'full_name') as String?, avatarUrl: RealmUserUpdateEvent._readFromPerson(json, 'avatar_url') as String?, @@ -151,11 +149,11 @@ RealmUserUpdateEvent _$RealmUserUpdateEventFromJson( ), customProfileField: RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') == null - ? null - : RealmUserUpdateCustomProfileField.fromJson( - RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') - as Map, - ), + ? null + : RealmUserUpdateCustomProfileField.fromJson( + RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') + as Map, + ), newEmail: RealmUserUpdateEvent._readFromPerson(json, 'new_email') as String?, isActive: RealmUserUpdateEvent._readFromPerson(json, 'is_active') as bool?, ); @@ -255,10 +253,9 @@ Map _$SavedSnippetsRemoveEventToJson( ChannelCreateEvent _$ChannelCreateEventFromJson(Map json) => ChannelCreateEvent( id: (json['id'] as num).toInt(), - streams: - (json['streams'] as List) - .map((e) => ZulipStream.fromJson(e as Map)) - .toList(), + streams: (json['streams'] as List) + .map((e) => ZulipStream.fromJson(e as Map)) + .toList(), ); Map _$ChannelCreateEventToJson(ChannelCreateEvent instance) => @@ -272,10 +269,9 @@ Map _$ChannelCreateEventToJson(ChannelCreateEvent instance) => ChannelDeleteEvent _$ChannelDeleteEventFromJson(Map json) => ChannelDeleteEvent( id: (json['id'] as num).toInt(), - streams: - (json['streams'] as List) - .map((e) => ZulipStream.fromJson(e as Map)) - .toList(), + streams: (json['streams'] as List) + .map((e) => ZulipStream.fromJson(e as Map)) + .toList(), ); Map _$ChannelDeleteEventToJson(ChannelDeleteEvent instance) => @@ -331,10 +327,9 @@ SubscriptionAddEvent _$SubscriptionAddEventFromJson( Map json, ) => SubscriptionAddEvent( id: (json['id'] as num).toInt(), - subscriptions: - (json['subscriptions'] as List) - .map((e) => Subscription.fromJson(e as Map)) - .toList(), + subscriptions: (json['subscriptions'] as List) + .map((e) => Subscription.fromJson(e as Map)) + .toList(), ); Map _$SubscriptionAddEventToJson( @@ -403,14 +398,12 @@ SubscriptionPeerAddEvent _$SubscriptionPeerAddEventFromJson( Map json, ) => SubscriptionPeerAddEvent( id: (json['id'] as num).toInt(), - streamIds: - (json['stream_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - userIds: - (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + streamIds: (json['stream_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$SubscriptionPeerAddEventToJson( @@ -427,14 +420,12 @@ SubscriptionPeerRemoveEvent _$SubscriptionPeerRemoveEventFromJson( Map json, ) => SubscriptionPeerRemoveEvent( id: (json['id'] as num).toInt(), - streamIds: - (json['stream_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - userIds: - (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + streamIds: (json['stream_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$SubscriptionPeerRemoveEventToJson( @@ -498,14 +489,12 @@ UpdateMessageEvent _$UpdateMessageEventFromJson(Map json) => userId: (json['user_id'] as num?)?.toInt(), renderingOnly: json['rendering_only'] as bool?, messageId: (json['message_id'] as num).toInt(), - messageIds: - (json['message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), - flags: - (json['flags'] as List) - .map((e) => $enumDecode(_$MessageFlagEnumMap, e)) - .toList(), + messageIds: (json['message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), + flags: (json['flags'] as List) + .map((e) => $enumDecode(_$MessageFlagEnumMap, e)) + .toList(), editTimestamp: (json['edit_timestamp'] as num?)?.toInt(), moveData: UpdateMessageMoveData.tryParseFromJson( UpdateMessageEvent._readMoveData(json, 'move_data') @@ -549,18 +538,16 @@ const _$MessageFlagEnumMap = { DeleteMessageEvent _$DeleteMessageEventFromJson(Map json) => DeleteMessageEvent( id: (json['id'] as num).toInt(), - messageIds: - (json['message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + messageIds: (json['message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), messageType: const MessageTypeConverter().fromJson( json['message_type'] as String, ), streamId: (json['stream_id'] as num?)?.toInt(), - topic: - json['topic'] == null - ? null - : TopicName.fromJson(json['topic'] as String), + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$DeleteMessageEventToJson(DeleteMessageEvent instance) => @@ -582,10 +569,9 @@ UpdateMessageFlagsAddEvent _$UpdateMessageFlagsAddEventFromJson( json['flag'], unknownValue: MessageFlag.unknown, ), - messages: - (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), all: json['all'] as bool, ); @@ -609,10 +595,9 @@ UpdateMessageFlagsRemoveEvent _$UpdateMessageFlagsRemoveEventFromJson( json['flag'], unknownValue: MessageFlag.unknown, ), - messages: - (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), messageDetails: (json['message_details'] as Map?)?.map( (k, e) => MapEntry( int.parse(k), @@ -639,15 +624,13 @@ UpdateMessageFlagsMessageDetail _$UpdateMessageFlagsMessageDetailFromJson( ) => UpdateMessageFlagsMessageDetail( type: const MessageTypeConverter().fromJson(json['type'] as String), mentioned: json['mentioned'] as bool?, - userIds: - (json['user_ids'] as List?) - ?.map((e) => (e as num).toInt()) - .toList(), + userIds: (json['user_ids'] as List?) + ?.map((e) => (e as num).toInt()) + .toList(), streamId: (json['stream_id'] as num?)?.toInt(), - topic: - json['topic'] == null - ? null - : TopicName.fromJson(json['topic'] as String), + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$UpdateMessageFlagsMessageDetailToJson( @@ -699,10 +682,9 @@ TypingEvent _$TypingEventFromJson(Map json) => TypingEvent( senderId: (TypingEvent._readSenderId(json, 'sender_id') as num).toInt(), recipientIds: TypingEvent._recipientIdsFromJson(json['recipients']), streamId: (json['stream_id'] as num?)?.toInt(), - topic: - json['topic'] == null - ? null - : TopicName.fromJson(json['topic'] as String), + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$TypingEventToJson(TypingEvent instance) => diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 36afb0a39f..5574f8dde7 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -16,12 +16,12 @@ InitialSnapshot _$InitialSnapshotFromJson( zulipFeatureLevel: (json['zulip_feature_level'] as num).toInt(), zulipVersion: json['zulip_version'] as String, zulipMergeBase: json['zulip_merge_base'] as String?, - alertWords: - (json['alert_words'] as List).map((e) => e as String).toList(), - customProfileFields: - (json['custom_profile_fields'] as List) - .map((e) => CustomProfileField.fromJson(e as Map)) - .toList(), + alertWords: (json['alert_words'] as List) + .map((e) => e as String) + .toList(), + customProfileFields: (json['custom_profile_fields'] as List) + .map((e) => CustomProfileField.fromJson(e as Map)) + .toList(), emailAddressVisibility: $enumDecodeNullable( _$EmailAddressVisibilityEnumMap, json['email_address_visibility'], @@ -45,38 +45,31 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['recent_private_conversations'] as List) .map((e) => RecentDmConversation.fromJson(e as Map)) .toList(), - savedSnippets: - (json['saved_snippets'] as List?) - ?.map((e) => SavedSnippet.fromJson(e as Map)) - .toList(), - subscriptions: - (json['subscriptions'] as List) - .map((e) => Subscription.fromJson(e as Map)) - .toList(), + savedSnippets: (json['saved_snippets'] as List?) + ?.map((e) => SavedSnippet.fromJson(e as Map)) + .toList(), + subscriptions: (json['subscriptions'] as List) + .map((e) => Subscription.fromJson(e as Map)) + .toList(), unreadMsgs: UnreadMessagesSnapshot.fromJson( json['unread_msgs'] as Map, ), - streams: - (json['streams'] as List) - .map((e) => ZulipStream.fromJson(e as Map)) - .toList(), - userSettings: - json['user_settings'] == null - ? null - : UserSettings.fromJson( - json['user_settings'] as Map, - ), - userTopics: - (json['user_topics'] as List?) - ?.map((e) => UserTopicItem.fromJson(e as Map)) - .toList(), + streams: (json['streams'] as List) + .map((e) => ZulipStream.fromJson(e as Map)) + .toList(), + userSettings: json['user_settings'] == null + ? null + : UserSettings.fromJson(json['user_settings'] as Map), + userTopics: (json['user_topics'] as List?) + ?.map((e) => UserTopicItem.fromJson(e as Map)) + .toList(), realmWildcardMentionPolicy: $enumDecode( _$RealmWildcardMentionPolicyEnumMap, json['realm_wildcard_mention_policy'], ), realmMandatoryTopics: json['realm_mandatory_topics'] as bool, - realmWaitingPeriodThreshold: - (json['realm_waiting_period_threshold'] as num).toInt(), + realmWaitingPeriodThreshold: (json['realm_waiting_period_threshold'] as num) + .toInt(), realmAllowMessageEditing: json['realm_allow_message_editing'] as bool, realmMessageContentEditLimitSeconds: (json['realm_message_content_edit_limit_seconds'] as num?)?.toInt(), @@ -88,10 +81,9 @@ InitialSnapshot _$InitialSnapshotFromJson( ), ), maxFileUploadSizeMib: (json['max_file_upload_size_mib'] as num).toInt(), - serverEmojiDataUrl: - json['server_emoji_data_url'] == null - ? null - : Uri.parse(json['server_emoji_data_url'] as String), + serverEmojiDataUrl: json['server_emoji_data_url'] == null + ? null + : Uri.parse(json['server_emoji_data_url'] as String), realmEmptyTopicDisplayName: json['realm_empty_topic_display_name'] as String?, realmUsers: (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users') @@ -192,10 +184,9 @@ RecentDmConversation _$RecentDmConversationFromJson( Map json, ) => RecentDmConversation( maxMessageId: (json['max_message_id'] as num).toInt(), - userIds: - (json['user_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$RecentDmConversationToJson( @@ -263,22 +254,18 @@ UnreadMessagesSnapshot _$UnreadMessagesSnapshotFromJson( Map json, ) => UnreadMessagesSnapshot( count: (json['count'] as num).toInt(), - dms: - (json['pms'] as List) - .map((e) => UnreadDmSnapshot.fromJson(e as Map)) - .toList(), - channels: - (json['streams'] as List) - .map((e) => UnreadChannelSnapshot.fromJson(e as Map)) - .toList(), - huddles: - (json['huddles'] as List) - .map((e) => UnreadHuddleSnapshot.fromJson(e as Map)) - .toList(), - mentions: - (json['mentions'] as List) - .map((e) => (e as num).toInt()) - .toList(), + dms: (json['pms'] as List) + .map((e) => UnreadDmSnapshot.fromJson(e as Map)) + .toList(), + channels: (json['streams'] as List) + .map((e) => UnreadChannelSnapshot.fromJson(e as Map)) + .toList(), + huddles: (json['huddles'] as List) + .map((e) => UnreadHuddleSnapshot.fromJson(e as Map)) + .toList(), + mentions: (json['mentions'] as List) + .map((e) => (e as num).toInt()) + .toList(), oldUnreadsMissing: json['old_unreads_missing'] as bool, ); @@ -298,10 +285,9 @@ UnreadDmSnapshot _$UnreadDmSnapshotFromJson(Map json) => otherUserId: (UnreadDmSnapshot._readOtherUserId(json, 'other_user_id') as num) .toInt(), - unreadMessageIds: - (json['unread_message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + unreadMessageIds: (json['unread_message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$UnreadDmSnapshotToJson(UnreadDmSnapshot instance) => @@ -315,10 +301,9 @@ UnreadChannelSnapshot _$UnreadChannelSnapshotFromJson( ) => UnreadChannelSnapshot( topic: TopicName.fromJson(json['topic'] as String), streamId: (json['stream_id'] as num).toInt(), - unreadMessageIds: - (json['unread_message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + unreadMessageIds: (json['unread_message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$UnreadChannelSnapshotToJson( @@ -333,10 +318,9 @@ UnreadHuddleSnapshot _$UnreadHuddleSnapshotFromJson( Map json, ) => UnreadHuddleSnapshot( userIdsString: json['user_ids_string'] as String, - unreadMessageIds: - (json['unread_message_ids'] as List) - .map((e) => (e as num).toInt()) - .toList(), + unreadMessageIds: (json['unread_message_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$UnreadHuddleSnapshotToJson( diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 67fc606031..6f351d0a6f 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -107,14 +107,14 @@ User _$UserFromJson(Map json) => User( timezone: json['timezone'] as String, avatarUrl: json['avatar_url'] as String?, avatarVersion: (json['avatar_version'] as num).toInt(), - profileData: (User._readProfileData(json, 'profile_data') - as Map?) - ?.map( - (k, e) => MapEntry( - int.parse(k), - ProfileFieldUserData.fromJson(e as Map), - ), - ), + profileData: + (User._readProfileData(json, 'profile_data') as Map?) + ?.map( + (k, e) => MapEntry( + int.parse(k), + ProfileFieldUserData.fromJson(e as Map), + ), + ), isSystemBot: User._readIsSystemBot(json, 'is_system_bot') as bool, ); diff --git a/lib/api/route/channels.g.dart b/lib/api/route/channels.g.dart index f12b4db05f..c43f0f50f0 100644 --- a/lib/api/route/channels.g.dart +++ b/lib/api/route/channels.g.dart @@ -11,10 +11,9 @@ part of 'channels.dart'; GetStreamTopicsResult _$GetStreamTopicsResultFromJson( Map json, ) => GetStreamTopicsResult( - topics: - (json['topics'] as List) - .map((e) => GetStreamTopicsEntry.fromJson(e as Map)) - .toList(), + topics: (json['topics'] as List) + .map((e) => GetStreamTopicsEntry.fromJson(e as Map)) + .toList(), ); Map _$GetStreamTopicsResultToJson( diff --git a/lib/api/route/events.g.dart b/lib/api/route/events.g.dart index 5866787fc6..3c77877ae8 100644 --- a/lib/api/route/events.g.dart +++ b/lib/api/route/events.g.dart @@ -10,10 +10,9 @@ part of 'events.dart'; GetEventsResult _$GetEventsResultFromJson(Map json) => GetEventsResult( - events: - (json['events'] as List) - .map((e) => Event.fromJson(e as Map)) - .toList(), + events: (json['events'] as List) + .map((e) => Event.fromJson(e as Map)) + .toList(), queueId: json['queue_id'] as String?, ); diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index 21729f04da..0df3e678e6 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -58,10 +58,9 @@ Map _$UploadFileResultToJson(UploadFileResult instance) => UpdateMessageFlagsResult _$UpdateMessageFlagsResultFromJson( Map json, ) => UpdateMessageFlagsResult( - messages: - (json['messages'] as List) - .map((e) => (e as num).toInt()) - .toList(), + messages: (json['messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$UpdateMessageFlagsResultToJson( diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart index 19c9f35c5a..9ff8b71b65 100644 --- a/lib/model/database.g.dart +++ b/lib/model/database.g.dart @@ -22,26 +22,28 @@ class $GlobalSettingsTable extends GlobalSettings ).withConverter($GlobalSettingsTable.$converterthemeSettingn); @override late final GeneratedColumnWithTypeConverter - browserPreference = GeneratedColumn( - 'browser_preference', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ).withConverter( - $GlobalSettingsTable.$converterbrowserPreferencen, - ); + browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$converterbrowserPreferencen, + ); @override late final GeneratedColumnWithTypeConverter - visitFirstUnread = GeneratedColumn( - 'visit_first_unread', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ).withConverter( - $GlobalSettingsTable.$convertervisitFirstUnreadn, - ); + visitFirstUnread = + GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$convertervisitFirstUnreadn, + ); @override List get $columns => [ themeSetting, @@ -150,18 +152,15 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), - visitFirstUnread: - visitFirstUnread == null && nullToAbsent - ? const Value.absent() - : Value(visitFirstUnread), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), ); } @@ -206,29 +205,24 @@ class GlobalSettingsData extends DataClass Value visitFirstUnread = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, - visitFirstUnread: - visitFirstUnread.present - ? visitFirstUnread.value - : this.visitFirstUnread, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, - visitFirstUnread: - data.visitFirstUnread.present - ? data.visitFirstUnread.value - : this.visitFirstUnread, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, ); } @@ -405,16 +399,14 @@ class $BoolGlobalSettingsTable extends BoolGlobalSettings BoolGlobalSettingRow map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BoolGlobalSettingRow( - name: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}name'], - )!, - value: - attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}value'], - )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, ); } @@ -771,46 +763,40 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { Account map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return Account( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, realmUrl: $AccountsTable.$converterrealmUrl.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}realm_url'], )!, ), - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -893,15 +879,13 @@ class Account extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -955,11 +939,13 @@ class Account extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); Account copyWithCompanion(AccountsCompanion data) { return Account( @@ -968,22 +954,18 @@ class Account extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } @@ -1314,16 +1296,12 @@ class $$GlobalSettingsTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: - () => $$GlobalSettingsTableFilterComposer($db: db, $table: table), - createOrderingComposer: - () => - $$GlobalSettingsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: - () => $$GlobalSettingsTableAnnotationComposer( - $db: db, - $table: table, - ), + createFilteringComposer: () => + $$GlobalSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$GlobalSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$GlobalSettingsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value themeSetting = const Value.absent(), @@ -1352,16 +1330,9 @@ class $$GlobalSettingsTableTableManager visitFirstUnread: visitFirstUnread, rowid: rowid, ), - withReferenceMapper: - (p0) => - p0 - .map( - (e) => ( - e.readTable(table), - BaseReferences(db, table, e), - ), - ) - .toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -1482,18 +1453,12 @@ class $$BoolGlobalSettingsTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: - () => $$BoolGlobalSettingsTableFilterComposer( - $db: db, - $table: table, - ), - createOrderingComposer: - () => $$BoolGlobalSettingsTableOrderingComposer( - $db: db, - $table: table, - ), - createComputedFieldComposer: - () => $$BoolGlobalSettingsTableAnnotationComposer( + createFilteringComposer: () => + $$BoolGlobalSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$BoolGlobalSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$BoolGlobalSettingsTableAnnotationComposer( $db: db, $table: table, ), @@ -1517,16 +1482,9 @@ class $$BoolGlobalSettingsTableTableManager value: value, rowid: rowid, ), - withReferenceMapper: - (p0) => - p0 - .map( - (e) => ( - e.readTable(table), - BaseReferences(db, table, e), - ), - ) - .toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -1754,12 +1712,12 @@ class $$AccountsTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: - () => $$AccountsTableFilterComposer($db: db, $table: table), - createOrderingComposer: - () => $$AccountsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: - () => $$AccountsTableAnnotationComposer($db: db, $table: table), + createFilteringComposer: () => + $$AccountsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$AccountsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$AccountsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), @@ -1804,16 +1762,9 @@ class $$AccountsTableTableManager zulipFeatureLevel: zulipFeatureLevel, ackedPushToken: ackedPushToken, ), - withReferenceMapper: - (p0) => - p0 - .map( - (e) => ( - e.readTable(table), - BaseReferences(db, table, e), - ), - ) - .toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); diff --git a/pubspec.lock b/pubspec.lock index 15cd95309f..0d0b1a669b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.0" dbus: dependency: transitive description: diff --git a/test/model/schemas/schema_v1.dart b/test/model/schemas/schema_v1.dart index a3f326e1d3..9629b868f7 100644 --- a/test/model/schemas/schema_v1.dart +++ b/test/model/schemas/schema_v1.dart @@ -95,45 +95,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ); } @@ -186,10 +179,9 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), ); } @@ -241,8 +233,9 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, ); AccountsData copyWithCompanion(AccountsCompanion data) { @@ -252,18 +245,15 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, ); } diff --git a/test/model/schemas/schema_v2.dart b/test/model/schemas/schema_v2.dart index f31a7934e0..61c69dd90c 100644 --- a/test/model/schemas/schema_v2.dart +++ b/test/model/schemas/schema_v2.dart @@ -103,45 +103,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -203,15 +196,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -265,11 +256,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -278,22 +271,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v3.dart b/test/model/schemas/schema_v3.dart index 7a78e85840..862ea42c18 100644 --- a/test/model/schemas/schema_v3.dart +++ b/test/model/schemas/schema_v3.dart @@ -57,10 +57,9 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), ); } @@ -88,10 +87,9 @@ class GlobalSettingsData extends DataClass ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, ); } @@ -264,45 +262,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -364,15 +355,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -426,11 +415,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -439,22 +430,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v4.dart b/test/model/schemas/schema_v4.dart index e53e4fbe2a..631d37ab82 100644 --- a/test/model/schemas/schema_v4.dart +++ b/test/model/schemas/schema_v4.dart @@ -73,14 +73,12 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), ); } @@ -110,21 +108,18 @@ class GlobalSettingsData extends DataClass Value browserPreference = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, ); } @@ -311,45 +306,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -411,15 +399,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -473,11 +459,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -486,22 +474,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v5.dart b/test/model/schemas/schema_v5.dart index 3bf383ef27..1d3bc4d895 100644 --- a/test/model/schemas/schema_v5.dart +++ b/test/model/schemas/schema_v5.dart @@ -73,14 +73,12 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), ); } @@ -110,21 +108,18 @@ class GlobalSettingsData extends DataClass Value browserPreference = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, ); } @@ -311,45 +306,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -411,15 +399,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -473,11 +459,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -486,22 +474,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v6.dart b/test/model/schemas/schema_v6.dart index 17ff55be21..aac90f3ae3 100644 --- a/test/model/schemas/schema_v6.dart +++ b/test/model/schemas/schema_v6.dart @@ -73,14 +73,12 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), ); } @@ -110,21 +108,18 @@ class GlobalSettingsData extends DataClass Value browserPreference = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, ); } @@ -247,16 +242,14 @@ class BoolGlobalSettings extends Table BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BoolGlobalSettingsData( - name: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}name'], - )!, - value: - attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}value'], - )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, ); } @@ -499,45 +492,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -599,15 +585,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -661,11 +645,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -674,22 +660,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } diff --git a/test/model/schemas/schema_v7.dart b/test/model/schemas/schema_v7.dart index dd3951b800..b74f391386 100644 --- a/test/model/schemas/schema_v7.dart +++ b/test/model/schemas/schema_v7.dart @@ -96,18 +96,15 @@ class GlobalSettingsData extends DataClass GlobalSettingsCompanion toCompanion(bool nullToAbsent) { return GlobalSettingsCompanion( - themeSetting: - themeSetting == null && nullToAbsent - ? const Value.absent() - : Value(themeSetting), - browserPreference: - browserPreference == null && nullToAbsent - ? const Value.absent() - : Value(browserPreference), - visitFirstUnread: - visitFirstUnread == null && nullToAbsent - ? const Value.absent() - : Value(visitFirstUnread), + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), ); } @@ -140,29 +137,24 @@ class GlobalSettingsData extends DataClass Value visitFirstUnread = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, - browserPreference: - browserPreference.present - ? browserPreference.value - : this.browserPreference, - visitFirstUnread: - visitFirstUnread.present - ? visitFirstUnread.value - : this.visitFirstUnread, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( - themeSetting: - data.themeSetting.present - ? data.themeSetting.value - : this.themeSetting, - browserPreference: - data.browserPreference.present - ? data.browserPreference.value - : this.browserPreference, - visitFirstUnread: - data.visitFirstUnread.present - ? data.visitFirstUnread.value - : this.visitFirstUnread, + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, ); } @@ -299,16 +291,14 @@ class BoolGlobalSettings extends Table BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BoolGlobalSettingsData( - name: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}name'], - )!, - value: - attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}value'], - )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, ); } @@ -551,45 +541,38 @@ class Accounts extends Table with TableInfo { AccountsData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return AccountsData( - id: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - realmUrl: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}realm_url'], - )!, - userId: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}user_id'], - )!, - email: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}email'], - )!, - apiKey: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}api_key'], - )!, - zulipVersion: - attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}zulip_version'], - )!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, zulipMergeBase: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}zulip_merge_base'], ), - zulipFeatureLevel: - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}zulip_feature_level'], - )!, + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, ackedPushToken: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}acked_push_token'], @@ -651,15 +634,13 @@ class AccountsData extends DataClass implements Insertable { email: Value(email), apiKey: Value(apiKey), zulipVersion: Value(zulipVersion), - zulipMergeBase: - zulipMergeBase == null && nullToAbsent - ? const Value.absent() - : Value(zulipMergeBase), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), zulipFeatureLevel: Value(zulipFeatureLevel), - ackedPushToken: - ackedPushToken == null && nullToAbsent - ? const Value.absent() - : Value(ackedPushToken), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), ); } @@ -713,11 +694,13 @@ class AccountsData extends DataClass implements Insertable { email: email ?? this.email, apiKey: apiKey ?? this.apiKey, zulipVersion: zulipVersion ?? this.zulipVersion, - zulipMergeBase: - zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, - ackedPushToken: - ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, ); AccountsData copyWithCompanion(AccountsCompanion data) { return AccountsData( @@ -726,22 +709,18 @@ class AccountsData extends DataClass implements Insertable { userId: data.userId.present ? data.userId.value : this.userId, email: data.email.present ? data.email.value : this.email, apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, - zulipVersion: - data.zulipVersion.present - ? data.zulipVersion.value - : this.zulipVersion, - zulipMergeBase: - data.zulipMergeBase.present - ? data.zulipMergeBase.value - : this.zulipMergeBase, - zulipFeatureLevel: - data.zulipFeatureLevel.present - ? data.zulipFeatureLevel.value - : this.zulipFeatureLevel, - ackedPushToken: - data.ackedPushToken.present - ? data.ackedPushToken.value - : this.ackedPushToken, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, ); } From 56927883c29eb49062768335adbf536c6e19c1a8 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 01:41:13 +0530 Subject: [PATCH 178/290] deps: Update pigeon to 25.3.2, from 25.3.1 Changelog: https://pub.dev/packages/pigeon/changelog#2532 --- .../main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt | 2 +- lib/host/android_notifications.g.dart | 2 +- lib/host/notifications.g.dart | 2 +- pubspec.lock | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt index 39207e3470..56c2f99aaf 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// Autogenerated from Pigeon (v25.3.2), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") diff --git a/lib/host/android_notifications.g.dart b/lib/host/android_notifications.g.dart index 5f46d154e9..de56806a4b 100644 --- a/lib/host/android_notifications.g.dart +++ b/lib/host/android_notifications.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// Autogenerated from Pigeon (v25.3.2), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart index a83b67c804..ce1a74d446 100644 --- a/lib/host/notifications.g.dart +++ b/lib/host/notifications.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// Autogenerated from Pigeon (v25.3.2), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers diff --git a/pubspec.lock b/pubspec.lock index 0d0b1a669b..7e5e26b2ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -834,10 +834,10 @@ packages: dependency: "direct dev" description: name: pigeon - sha256: "3e4e6258f22760fa11f86d2a5202fb3f8367cb361d33bd9a93de85a7959e9976" + sha256: a093af76026160bb5ff6eb98e3e678a301ffd1001ac0d90be558bc133a0c73f5 url: "https://pub.dev" source: hosted - version: "25.3.1" + version: "25.3.2" platform: dependency: transitive description: From 652784610957750b470de2988f84d62335b59401 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 02:10:21 +0530 Subject: [PATCH 179/290] deps: Update file_picker to 10.2.0, from 10.1.9 Changelog: https://pub.dev/packages/file_picker/changelog#1020 One bug fix on Android, for `saveFile`, which we don't use. Also, update lint-baseline.xml using `gradlew updateLintBaseline`. --- android/app/lint-baseline.xml | 4 ++-- pubspec.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml index a3d1aeac5c..be85415f4e 100644 --- a/android/app/lint-baseline.xml +++ b/android/app/lint-baseline.xml @@ -1,12 +1,12 @@ - + + file="$GRADLE_USER_HOME/caches/modules-2/files-2.1/org.apache.tika/tika-core/3.2.0/9232bb3c71f231e8228f570071c0e1ea29d40115/tika-core-3.2.0.jar"/> diff --git a/pubspec.lock b/pubspec.lock index 7e5e26b2ad..571ed78eb5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -318,10 +318,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" + sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a url: "https://pub.dev" source: hosted - version: "10.1.9" + version: "10.2.0" file_selector_linux: dependency: transitive description: From 2036178c8cf57347bbcbcd51c6448a5ad47a3b6c Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 01:51:54 +0530 Subject: [PATCH 180/290] deps: Pin video_player to 2.9.5 The latest version (2.10.0) makes significant changes internally and to the test API, which would require us to investigate and update our FakeVideoPlayerPlatform mock for tests. So, pin to the currently used version for now. --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 1f0d12f259..c5777527c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,7 +61,7 @@ dependencies: sqlite3_flutter_libs: ^0.5.13 url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" - video_player: ^2.8.3 + video_player: 2.9.5 # TODO unpin and upgrade to latest version wakelock_plus: ^1.2.8 zulip_plugin: path: ./packages/zulip_plugin From 32401c6e46cd3ea92e80681b87cdb256fce599f1 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 02:11:52 +0530 Subject: [PATCH 181/290] deps: Upgrade packages within constraints (tools/upgrade pub) --- ios/Podfile.lock | 22 +++++++++++----------- macos/Podfile.lock | 22 +++++++++++----------- pubspec.lock | 44 ++++++++++++++++++++++---------------------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7ab5f37048..009b8fb7c6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -117,23 +117,23 @@ PODS: - SDWebImage/Core (5.21.1) - share_plus (0.0.1): - Flutter - - sqlite3 (3.49.2): - - sqlite3/common (= 3.49.2) - - sqlite3/common (3.49.2) - - sqlite3/dbstatvtab (3.49.2): + - sqlite3 (3.50.1): + - sqlite3/common (= 3.50.1) + - sqlite3/common (3.50.1) + - sqlite3/dbstatvtab (3.50.1): - sqlite3/common - - sqlite3/fts5 (3.49.2): + - sqlite3/fts5 (3.50.1): - sqlite3/common - - sqlite3/math (3.49.2): + - sqlite3/math (3.50.1): - sqlite3/common - - sqlite3/perf-threadsafe (3.49.2): + - sqlite3/perf-threadsafe (3.50.1): - sqlite3/common - - sqlite3/rtree (3.49.2): + - sqlite3/rtree (3.50.1): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.49.1) + - sqlite3 (~> 3.50.1) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/math @@ -238,8 +238,8 @@ SPEC CHECKSUMS: PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 SDWebImage: f29024626962457f3470184232766516dee8dfea share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 - sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 + sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5 + sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 33a9c67b5c..eb96189d39 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -81,23 +81,23 @@ PODS: - PromisesObjC (2.4.0) - share_plus (0.0.1): - FlutterMacOS - - sqlite3 (3.49.2): - - sqlite3/common (= 3.49.2) - - sqlite3/common (3.49.2) - - sqlite3/dbstatvtab (3.49.2): + - sqlite3 (3.50.1): + - sqlite3/common (= 3.50.1) + - sqlite3/common (3.50.1) + - sqlite3/dbstatvtab (3.50.1): - sqlite3/common - - sqlite3/fts5 (3.49.2): + - sqlite3/fts5 (3.50.1): - sqlite3/common - - sqlite3/math (3.49.2): + - sqlite3/math (3.50.1): - sqlite3/common - - sqlite3/perf-threadsafe (3.49.2): + - sqlite3/perf-threadsafe (3.50.1): - sqlite3/common - - sqlite3/rtree (3.49.2): + - sqlite3/rtree (3.50.1): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.49.1) + - sqlite3 (~> 3.50.1) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/math @@ -190,8 +190,8 @@ SPEC CHECKSUMS: path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 - sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 + sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5 + sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497 diff --git a/pubspec.lock b/pubspec.lock index 571ed78eb5..e3025cdc6b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" url: "https://pub.dev" source: hosted - version: "8.9.5" + version: "8.10.1" characters: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" checks: dependency: "direct dev" description: @@ -214,10 +214,10 @@ packages: dependency: transitive description: name: coverage - sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" + sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 url: "https://pub.dev" source: hosted - version: "1.13.1" + version: "1.14.1" cross_file: dependency: transitive description: @@ -334,10 +334,10 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" url: "https://pub.dev" source: hosted - version: "0.9.4+2" + version: "0.9.4+3" file_selector_platform_interface: dependency: transitive description: @@ -874,10 +874,10 @@ packages: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + sha256: "44b4226c0afd4bc3b7c7e67d44c4801abd97103cf0c84609e2654b664ca2798c" url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.4" pub_semver: dependency: transitive description: @@ -1007,18 +1007,18 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" + sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38 url: "https://pub.dev" source: hosted - version: "2.7.5" + version: "2.7.6" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14" + sha256: e07232b998755fe795655c56d1f5426e0190c9c435e1752d39e7b1cd33699c71 url: "https://pub.dev" source: hosted - version: "0.5.32" + version: "0.5.34" sqlparser: dependency: transitive description: @@ -1207,10 +1207,10 @@ packages: dependency: transitive description: name: video_player_android - sha256: "1f4e8e0e02403452d699ef7cf73fe9936fac8f6f0605303d111d71acb375d1bc" + sha256: "4a5135754a62dbc827a64a42ef1f8ed72c962e191c97e2d48744225c2b9ebb73" url: "https://pub.dev" source: hosted - version: "2.8.3" + version: "2.8.7" video_player_avfoundation: dependency: transitive description: @@ -1239,10 +1239,10 @@ packages: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.0.2" wakelock_plus: dependency: "direct main" description: @@ -1263,10 +1263,10 @@ packages: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" web: dependency: transitive description: @@ -1311,10 +1311,10 @@ packages: dependency: transitive description: name: win32 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "5.14.0" win32_registry: dependency: transitive description: From 57ccaca2da7c37fcdf80a5ec1f0dc4f05deaff56 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 02:20:24 +0530 Subject: [PATCH 182/290] deps android: Upgrade Gradle to 8.14.2, from 8.14 Changelogs: https://docs.gradle.org/8.14.1/release-notes.html https://docs.gradle.org/8.14.2/release-notes.html The update includes couple of bug fixes as this is a minor version release. --- android/gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 0af2956cea..877fe51457 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -6,7 +6,7 @@ # the wrapper is the one from the new Gradle too.) distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 3ef9de648cf88c8c21fd8e7fff87d96b75126bd0 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 02:26:22 +0530 Subject: [PATCH 183/290] deps android: Upgrade Android Gradle Plugin to 8.10.1, from 8.10.0 Changelog: https://developer.android.com/build/releases/gradle-plugin#android-gradle-plugin-8.10.1 This update include couple of bug fixes. --- android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index bf7487203d..6e0f68603b 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -6,7 +6,7 @@ android.enableJetifier=true # Defining them here makes them available both in # settings.gradle and in the build.gradle files. -agpVersion=8.10.0 +agpVersion=8.10.1 # Generally update this to the version found in recent releases # of Android Studio, as listed in this table: From fabd42f4c550b85db5f4ed12b850535901042543 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 29 May 2025 17:28:24 -0700 Subject: [PATCH 184/290] compose: Remove a redundant TypingNotifier.stoppedComposing call Issue #720 is superseded by #1441, in which we'll still clear the compose box when the send button is tapped. (We'll still preserve the composing progress in case the send fails, but we'll do so in an OutboxMessage instead of within the compose input in a disabled state.) --- lib/widgets/compose_box.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 57bf1d0a5c..e17edfdcdd 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1288,10 +1288,6 @@ class _SendButtonState extends State<_SendButton> { final content = controller.content.textNormalized; controller.content.clear(); - // The following `stoppedComposing` call is currently redundant, - // because clearing input sends a "typing stopped" notice. - // It will be necessary once we resolve #720. - store.typingNotifier.stoppedComposing(); try { // TODO(#720) clear content input only on success response; From a6709660b61b899115c166fd51ee062d0b559244 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 29 May 2025 18:51:30 -0700 Subject: [PATCH 185/290] compose [nfc]: Remove obsoleted TODO(#720)s Issue #720 is superseded by #1441, and these don't apply... I guess with the exception of a note on how a generic "x" button could be laid out, so we leave that. --- lib/widgets/compose_box.dart | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index e17edfdcdd..bcd65be0ba 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1290,9 +1290,6 @@ class _SendButtonState extends State<_SendButton> { controller.content.clear(); try { - // TODO(#720) clear content input only on success response; - // while waiting, put input(s) and send button into a disabled - // "working on it" state (letting input text be selected for copying). await store.sendMessage(destination: widget.getDestination(), content: content); } on ApiRequestException catch (e) { if (!mounted) return; @@ -1384,7 +1381,6 @@ class _ComposeBoxContainer extends StatelessWidget { border: Border(top: BorderSide(color: designVariables.borderBar)), boxShadow: ComposeBoxTheme.of(context).boxShadow, ), - // TODO(#720) try a Stack for the overlaid linear progress indicator child: Material( color: designVariables.composeBoxBg, child: Column( @@ -1742,10 +1738,10 @@ class _ErrorBanner extends _Banner { @override Widget? buildTrailing(context) { - // TODO(#720) "x" button goes here. - // 24px square with 8px touchable padding in all directions? - // and `bool get padEnd => false`; see Figma: - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-17029&m=dev + // An "x" button can go here. + // 24px square with 8px touchable padding in all directions? + // and `bool get padEnd => false`; see Figma: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-17029&m=dev return null; } } @@ -2083,11 +2079,6 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM } } - // TODO(#720) dismissable message-send error, maybe something like: - // if (controller.sendMessageError.value != null) { - // errorBanner = _ErrorBanner(label: - // ZulipLocalizations.of(context).errorSendMessageTimeout); - // } return ComposeBoxInheritedWidget.fromComposeBoxState(this, child: _ComposeBoxContainer(body: body, banner: banner)); } From 2003b6369c072121e8eded18aa702115b6000ec6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 29 May 2025 19:18:22 -0700 Subject: [PATCH 186/290] compose [nfc]: Expand a comment to include an edge case --- lib/widgets/compose_box.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index bcd65be0ba..9880a4b14f 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1941,7 +1941,8 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM // TODO timeout this request? if (!mounted) return; if (!identical(controller, emptyEditController)) { - // user tapped Cancel during the fetch-raw-content request + // During the fetch-raw-content request, the user tapped Cancel + // or tapped a failed message edit to restore. // TODO in this case we don't want the error dialog caused by // ZulipAction.fetchRawContentWithFeedback; suppress that return; From 7e2910882e4081f93b9594b807c9967a35cfa12c Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 1 Apr 2025 17:44:47 -0400 Subject: [PATCH 187/290] msglist: Support retrieving failed outbox message content Different from the Figma design, the bottom padding below the progress bar is changed from 0.5px to 2px, as discussed here: https://github.com/zulip/zulip-flutter/pull/1453#discussion_r2103709974 Fixes: #1441 Co-authored-by: Chris Bobbe --- assets/l10n/app_en.arb | 6 +- assets/l10n/app_pl.arb | 4 - assets/l10n/app_ru.arb | 4 - lib/generated/l10n/zulip_localizations.dart | 6 +- .../l10n/zulip_localizations_ar.dart | 4 +- .../l10n/zulip_localizations_de.dart | 4 +- .../l10n/zulip_localizations_en.dart | 4 +- .../l10n/zulip_localizations_ja.dart | 4 +- .../l10n/zulip_localizations_nb.dart | 4 +- .../l10n/zulip_localizations_pl.dart | 4 +- .../l10n/zulip_localizations_ru.dart | 4 +- .../l10n/zulip_localizations_sk.dart | 4 +- .../l10n/zulip_localizations_uk.dart | 4 +- .../l10n/zulip_localizations_zh.dart | 4 +- lib/model/message.dart | 5 +- lib/widgets/compose_box.dart | 36 +++- lib/widgets/message_list.dart | 109 +++++++++++- test/widgets/compose_box_test.dart | 159 ++++++++++++++++++ test/widgets/message_list_test.dart | 134 ++++++++++++++- 19 files changed, 457 insertions(+), 46 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1f070feb19..0d3f273d16 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -385,9 +385,9 @@ "@discardDraftForEditConfirmationDialogMessage": { "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." }, - "discardDraftForMessageNotSentConfirmationDialogMessage": "When you restore a message not sent, the content that was previously in the compose box is discarded.", - "@discardDraftForMessageNotSentConfirmationDialogMessage": { - "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." + "discardDraftForOutboxConfirmationDialogMessage": "When you restore an unsent message, the content that was previously in the compose box is discarded.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." }, "discardDraftConfirmationDialogConfirmButton": "Discard", "@discardDraftConfirmationDialogConfirmButton": { diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 0569169d4c..982ca98be4 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1113,10 +1113,6 @@ "@messageNotSentLabel": { "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, - "discardDraftForMessageNotSentConfirmationDialogMessage": "Odzyskanie wiadomości, która nie została wysłana, skutkuje wyczyszczeniem zawartości pola dodania wpisu.", - "@discardDraftForMessageNotSentConfirmationDialogMessage": { - "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." - }, "errorNotificationOpenAccountNotFound": "Nie odnaleziono konta powiązanego z tym powiadomieniem.", "@errorNotificationOpenAccountNotFound": { "description": "Error message when the account associated with the notification could not be found" diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index b752df8dab..eb79229d04 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1105,10 +1105,6 @@ "@newDmFabButtonLabel": { "description": "Label for the floating action button (FAB) that opens the new DM sheet." }, - "discardDraftForMessageNotSentConfirmationDialogMessage": "При восстановлении неотправленного сообщения текст в поле ввода текста будет утрачен.", - "@discardDraftForMessageNotSentConfirmationDialogMessage": { - "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." - }, "newDmSheetScreenTitle": "Новое ЛС", "@newDmSheetScreenTitle": { "description": "Title displayed at the top of the new DM screen." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 28f4eee3ba..68d47c3787 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -655,11 +655,11 @@ abstract class ZulipLocalizations { /// **'When you edit a message, the content that was previously in the compose box is discarded.'** String get discardDraftForEditConfirmationDialogMessage; - /// Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box. + /// Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box. /// /// In en, this message translates to: - /// **'When you restore a message not sent, the content that was previously in the compose box is discarded.'** - String get discardDraftForMessageNotSentConfirmationDialogMessage; + /// **'When you restore an unsent message, the content that was previously in the compose box is discarded.'** + String get discardDraftForOutboxConfirmationDialogMessage; /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 104cc16822..2910711c42 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 0abb3c2e55..a01b813f0f 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsDe extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index d5201bad84..9f41726924 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 69a2e97816..7d800ac7a8 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 66198f6a40..5d6c814002 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index b0189c01fc..efa03e9f48 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -333,8 +333,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'Odzyskanie wiadomości, która nie została wysłana, skutkuje wyczyszczeniem zawartości pola dodania wpisu.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 063b3c01e4..9d7b09ded9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -334,8 +334,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'При изменении сообщения текст из поля для редактирования удаляется.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'При восстановлении неотправленного сообщения текст в поле ввода текста будет утрачен.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index ec7a8f36e4..51aace2d53 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index af57ca0f86..97b5e26af1 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -334,8 +334,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 3b425dcea1..e72db65ad7 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsZh extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/model/message.dart b/lib/model/message.dart index e8cfa6e6e1..1dfe421368 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -881,9 +881,8 @@ mixin _OutboxMessageStore on PerAccountStoreBase { void _handleMessageEventOutbox(MessageEvent event) { if (event.localMessageId != null) { final localMessageId = int.parse(event.localMessageId!, radix: 10); - // The outbox message can be missing if the user removes it (to be - // implemented in #1441) before the event arrives. - // Nothing to do in that case. + // The outbox message can be missing if the user removes it before the + // event arrives. Nothing to do in that case. _outboxMessages.remove(localMessageId); _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 9880a4b14f..a53d628a2c 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -13,6 +13,7 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/binding.dart'; import '../model/compose.dart'; +import '../model/message.dart'; import '../model/narrow.dart'; import '../model/store.dart'; import 'actions.dart'; @@ -1840,6 +1841,16 @@ class ComposeBox extends StatefulWidget { abstract class ComposeBoxState extends State { ComposeBoxController get controller; + /// Fills the compose box with the content of an [OutboxMessage] + /// for a failed [sendMessage] request. + /// + /// If there is already text in the compose box, gives a confirmation dialog + /// to confirm that it is OK to discard that text. + /// + /// [localMessageId], as in [OutboxMessage.localMessageId], must be present + /// in the message store. + void restoreMessageNotSent(int localMessageId); + /// Switch the compose box to editing mode. /// /// If there is already text in the compose box, gives a confirmation dialog @@ -1861,6 +1872,29 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM @override ComposeBoxController get controller => _controller!; ComposeBoxController? _controller; + @override + void restoreMessageNotSent(int localMessageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + + final abort = await _abortBecauseContentInputNotEmpty( + dialogMessage: zulipLocalizations.discardDraftForOutboxConfirmationDialogMessage); + if (abort || !mounted) return; + + final store = PerAccountStoreWidget.of(context); + final outboxMessage = store.takeOutboxMessage(localMessageId); + setState(() { + _setNewController(store); + final controller = this.controller; + controller + ..content.value = TextEditingValue(text: outboxMessage.contentMarkdown) + ..contentFocusNode.requestFocus(); + if (controller is StreamComposeBoxController) { + controller.topic.setTopic( + (outboxMessage.conversation as StreamConversation).topic); + } + }); + } + @override void startEditInteraction(int messageId) async { final zulipLocalizations = ZulipLocalizations.of(context); @@ -1942,7 +1976,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM if (!mounted) return; if (!identical(controller, emptyEditController)) { // During the fetch-raw-content request, the user tapped Cancel - // or tapped a failed message edit to restore. + // or tapped a failed message edit or failed outbox message to restore. // TODO in this case we don't want the error dialog caused by // ZulipAction.fetchRawContentWithFeedback; suppress that return; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index b49e64a474..a34a25bb37 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/message.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; @@ -1748,19 +1749,113 @@ class OutboxMessageWithPossibleSender extends StatelessWidget { @override Widget build(BuildContext context) { final message = item.message; + final localMessageId = message.localMessageId; + + // This is adapted from [MessageContent]. + // TODO(#576): Offer InheritedMessage ancestor once we are ready + // to support local echoing images and lightbox. + Widget content = DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: item.content.nodes)); + + switch (message.state) { + case OutboxMessageState.hidden: + throw StateError('Hidden OutboxMessage messages should not appear in message lists'); + case OutboxMessageState.waiting: + break; + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + // TODO(#576): When we support rendered-content local echo, + // use IgnorePointer along with this faded appearance, + // like we do for the failed-message-edit state + content = _RestoreOutboxMessageGestureDetector( + localMessageId: localMessageId, + child: Opacity(opacity: 0.6, child: content)); + } + return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.only(top: 4), child: Column(children: [ if (item.showSender) _SenderRow(message: message, showTimestamp: false), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - // This is adapted from [MessageContent]. - // TODO(#576): Offer InheritedMessage ancestor once we are ready - // to support local echoing images and lightbox. - child: DefaultTextStyle( - style: ContentTheme.of(context).textStylePlainParagraph, - child: BlockContentList(nodes: item.content.nodes))), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + _OutboxMessageStatusRow( + localMessageId: localMessageId, outboxMessageState: message.state), + ])), ])); } } + +class _OutboxMessageStatusRow extends StatelessWidget { + const _OutboxMessageStatusRow({ + required this.localMessageId, + required this.outboxMessageState, + }); + + final int localMessageId; + final OutboxMessageState outboxMessageState; + + @override + Widget build(BuildContext context) { + switch (outboxMessageState) { + case OutboxMessageState.hidden: + assert(false, + 'Hidden OutboxMessage messages should not appear in message lists'); + return SizedBox.shrink(); + + case OutboxMessageState.waiting: + final designVariables = DesignVariables.of(context); + return Padding( + padding: const EdgeInsetsGeometry.only(bottom: 2), + child: LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withFadedAlpha(0.5), + backgroundColor: designVariables.foreground.withFadedAlpha(0.2))); + + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _RestoreOutboxMessageGestureDetector( + localMessageId: localMessageId, + child: Text( + zulipLocalizations.messageNotSentLabel, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.btnLabelAttLowIntDanger, + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))))); + } + } +} + +class _RestoreOutboxMessageGestureDetector extends StatelessWidget { + const _RestoreOutboxMessageGestureDetector({ + required this.localMessageId, + required this.child, + }); + + final int localMessageId; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + // TODO(#1518) allow restore-outbox-message from any message-list page + if (composeBoxState == null) return; + composeBoxState.restoreMessageNotSent(localMessageId); + }, + child: child); + } +} diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index c25b00793e..70f0913316 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1530,6 +1530,165 @@ void main() { } } + group('restoreMessageNotSent', () { + final channel = eg.stream(); + final topic = 'topic'; + final topicNarrow = eg.topicNarrow(channel.streamId, topic); + + final failedMessageContent = 'failed message'; + final failedMessageFinder = find.widgetWithText( + OutboxMessageWithPossibleSender, failedMessageContent, skipOffstage: true); + + Future prepareMessageNotSent(WidgetTester tester, { + required Narrow narrow, + List otherUsers = const [], + }) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + await prepareComposeBox(tester, + narrow: narrow, streams: [channel], otherUsers: otherUsers); + + if (narrow is ChannelNarrow) { + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await enterTopic(tester, narrow: narrow, topic: topic); + } + await enterContent(tester, failedMessageContent); + connection.prepare(httpException: SocketException('error')); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + check(state).controller.content.text.equals(''); + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Message not sent'))); + await tester.pump(); + check(failedMessageFinder).findsOne(); + } + + testWidgets('restore content in DM narrow', (tester) async { + final dmNarrow = DmNarrow.withUser( + eg.otherUser.userId, selfUserId: eg.selfUser.userId); + await prepareMessageNotSent(tester, narrow: dmNarrow, otherUsers: [eg.otherUser]); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('restore content in topic narrow', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('restore content and topic in channel narrow', (tester) async { + final channelNarrow = ChannelNarrow(channel.streamId); + await prepareMessageNotSent(tester, narrow: channelNarrow); + + await tester.enterText(topicInputFinder, 'topic before restoring'); + check(state).controller.isA() + ..topic.text.equals('topic before restoring') + ..content.text.isNotNull().isEmpty(); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.isA() + ..topic.text.equals(topic) + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + Future expectAndHandleDiscardForMessageNotSentConfirmation( + WidgetTester tester, { + required bool shouldContinue, + }) { + return expectAndHandleDiscardConfirmation(tester, + expectedMessage: 'When you restore an unsent message, the content that was previously in the compose box is discarded.', + shouldContinue: shouldContinue); + } + + testWidgets('interrupting new-message compose: proceed through confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + await enterContent(tester, 'composing something'); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: true); + await tester.pump(); + check(state).controller.content.text.equals(failedMessageContent); + }); + + testWidgets('interrupting new-message compose: cancel confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + await enterContent(tester, 'composing something'); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: false); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + }); + + testWidgets('interrupting message edit: proceed through confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + final messageToEdit = eg.streamMessage( + sender: eg.selfUser, stream: channel, topic: topic, + content: 'message to edit'); + await store.addMessage(messageToEdit); + await tester.pump(); + + await startEditInteractionFromActionSheet(tester, messageId: messageToEdit.id, + originalRawContent: 'message to edit', + delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 250)); // bottom-sheet animation + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: true); + await tester.pump(); + check(state).controller.content.text.equals(failedMessageContent); + }); + + testWidgets('interrupting message edit: cancel confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + final messageToEdit = eg.streamMessage( + sender: eg.selfUser, stream: channel, topic: topic, + content: 'message to edit'); + await store.addMessage(messageToEdit); + await tester.pump(); + + await startEditInteractionFromActionSheet(tester, messageId: messageToEdit.id, + originalRawContent: 'message to edit', + delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 250)); // bottom-sheet animation + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: false); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + }); + }); + group('edit message', () { final channel = eg.stream(); final topic = 'topic'; diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 01e40cf7cf..8ead103d68 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; @@ -1638,6 +1639,13 @@ void main() { Finder outboxMessageFinder = find.widgetWithText( OutboxMessageWithPossibleSender, content, skipOffstage: true); + Finder messageNotSentFinder = find.descendant( + of: find.byType(OutboxMessageWithPossibleSender), + matching: find.text('MESSAGE NOT SENT')).hitTestable(); + Finder loadingIndicatorFinder = find.descendant( + of: find.byType(OutboxMessageWithPossibleSender), + matching: find.byType(LinearProgressIndicator)).hitTestable(); + Future sendMessageAndSucceed(WidgetTester tester, { Duration delay = Duration.zero, }) async { @@ -1647,18 +1655,142 @@ void main() { await tester.pump(Duration.zero); } + Future sendMessageAndFail(WidgetTester tester, { + Duration delay = Duration.zero, + }) async { + connection.prepare(httpException: SocketException('error'), delay: delay); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + Future dismissErrorDialog(WidgetTester tester) async { + await tester.tap(find.byWidget( + checkErrorDialog(tester, expectedTitle: 'Message not sent'))); + await tester.pump(Duration(milliseconds: 250)); + } + + Future checkTapRestoreMessage(WidgetTester tester) async { + final state = tester.state(find.byType(ComposeBox)); + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + check(messageNotSentFinder).findsOne(); + check(state).controller.content.text.isNotNull().isEmpty(); + + // Tap the message. This should put its content back into the compose box + // and remove it. + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(store.outboxMessages).isEmpty(); + check(outboxMessageFinder).findsNothing(); + check(state).controller.content.text.equals(content); + } + + Future checkTapNotRestoreMessage(WidgetTester tester) async { + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + + // the message should ignore the pointer event + await tester.tap(outboxMessageFinder, warnIfMissed: false); + await tester.pump(); + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + } + // State transitions are tested more thoroughly in // test/model/message_test.dart . - testWidgets('hidden -> waiting, outbox message appear', (tester) async { + testWidgets('hidden -> waiting', (tester) async { await setupMessageListPage(tester, narrow: topicNarrow, streams: [stream], messages: []); + await sendMessageAndSucceed(tester); check(outboxMessageFinder).findsNothing(); await tester.pump(kLocalEchoDebounceDuration); check(outboxMessageFinder).findsOne(); + check(loadingIndicatorFinder).findsOne(); + // The outbox message is still in waiting state; + // tapping does not restore it. + await checkTapNotRestoreMessage(tester); + }); + + testWidgets('hidden -> failed, tap to restore message', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + // Send a message and fail. Dismiss the error dialog as it pops up. + await sendMessageAndFail(tester); + await dismissErrorDialog(tester); + check(messageNotSentFinder).findsOne(); + + await checkTapRestoreMessage(tester); + }); + + testWidgets('hidden -> failed, tapping does nothing if compose box is not offered', (tester) async { + Route? lastPoppedRoute; + final navObserver = TestNavigatorObserver() + ..onPopped = (route, prevRoute) => lastPoppedRoute = route; + + final messages = [eg.streamMessage( + stream: stream, topic: topic, content: content)]; + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), + streams: [stream], subscriptions: [eg.subscription(stream)], + navObservers: [navObserver], + messages: messages); + + // Navigate to a message list page in a topic narrow, + // which has a compose box. + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + await tester.tap(find.widgetWithText(RecipientHeader, topic)); + await tester.pump(); // handle tap + await tester.pump(); // wait for navigation + check(contentInputFinder).findsOne(); + + await sendMessageAndFail(tester); + await dismissErrorDialog(tester); + // Navigate back to the message list page without a compose box, + // where the failed to send message should be visible. + + await tester.pageBack(); + check(lastPoppedRoute) + .isA().page + .isA() + .initNarrow.equals(TopicNarrow(stream.streamId, eg.t(topic))); + await tester.pump(); // handle tap + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(contentInputFinder).findsNothing(); + check(messageNotSentFinder).findsOne(); + + // Tap the failed to send message. + // This should not remove it from the message list. + await checkTapNotRestoreMessage(tester); + }); + + testWidgets('waiting -> waitPeriodExpired, tap to restore message', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + await sendMessageAndFail(tester, + delay: kSendMessageOfferRestoreWaitPeriod + const Duration(seconds: 1)); + await tester.pump(kSendMessageOfferRestoreWaitPeriod); + final localMessageId = store.outboxMessages.keys.single; + check(messageNotSentFinder).findsOne(); + + await checkTapRestoreMessage(tester); + + // While `localMessageId` is no longer in store, there should be no error + // when a message event refers to it. + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: localMessageId)); + + // The [sendMessage] request fails; there is no outbox message affected. + await tester.pump(Duration(seconds: 1)); + check(messageNotSentFinder).findsNothing(); }); }); From 5caa859b3fa3a2f6e91fe5b811ad11414df8aa5f Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 19 Feb 2025 22:21:46 +0430 Subject: [PATCH 188/290] api: Add InitialSnapshot.mutedUsers Co-authored-by: Chris Bobbe --- lib/api/model/initial_snapshot.dart | 3 +++ lib/api/model/initial_snapshot.g.dart | 4 ++++ lib/api/model/model.dart | 19 +++++++++++++++++++ lib/api/model/model.g.dart | 6 ++++++ test/example_data.dart | 2 ++ 5 files changed, 34 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index f4cc2fe5fc..cb3df052ac 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -44,6 +44,8 @@ class InitialSnapshot { // final List<…> mutedTopics; // TODO(#422) we ignore this feature on older servers + final List mutedUsers; + final Map realmEmoji; final List recentPrivateConversations; @@ -132,6 +134,7 @@ class InitialSnapshot { required this.serverTypingStartedExpiryPeriodMilliseconds, required this.serverTypingStoppedWaitPeriodMilliseconds, required this.serverTypingStartedWaitPeriodMilliseconds, + required this.mutedUsers, required this.realmEmoji, required this.recentPrivateConversations, required this.savedSnippets, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 5574f8dde7..2cdd365ec5 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -38,6 +38,9 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['server_typing_started_wait_period_milliseconds'] as num?) ?.toInt() ?? 10000, + mutedUsers: (json['muted_users'] as List) + .map((e) => MutedUserItem.fromJson(e as Map)) + .toList(), realmEmoji: (json['realm_emoji'] as Map).map( (k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map)), ), @@ -122,6 +125,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => instance.serverTypingStoppedWaitPeriodMilliseconds, 'server_typing_started_wait_period_milliseconds': instance.serverTypingStartedWaitPeriodMilliseconds, + 'muted_users': instance.mutedUsers, 'realm_emoji': instance.realmEmoji, 'recent_private_conversations': instance.recentPrivateConversations, 'saved_snippets': instance.savedSnippets, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 131a51991b..87a617dc4d 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -110,6 +110,25 @@ class CustomProfileFieldExternalAccountData { Map toJson() => _$CustomProfileFieldExternalAccountDataToJson(this); } +/// An item in the [InitialSnapshot.mutedUsers]. +/// +/// For docs, search for "muted_users:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class MutedUserItem { + final int id; + + // Mobile doesn't use the timestamp; ignore. + // final int timestamp; + + const MutedUserItem({required this.id}); + + factory MutedUserItem.fromJson(Map json) => + _$MutedUserItemFromJson(json); + + Map toJson() => _$MutedUserItemToJson(this); +} + /// An item in [InitialSnapshot.realmEmoji] or [RealmEmojiUpdateEvent]. /// /// For docs, search for "realm_emoji:" diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 6f351d0a6f..8c56b4b7fb 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -68,6 +68,12 @@ Map _$CustomProfileFieldExternalAccountDataToJson( 'url_pattern': instance.urlPattern, }; +MutedUserItem _$MutedUserItemFromJson(Map json) => + MutedUserItem(id: (json['id'] as num).toInt()); + +Map _$MutedUserItemToJson(MutedUserItem instance) => + {'id': instance.id}; + RealmEmojiItem _$RealmEmojiItemFromJson(Map json) => RealmEmojiItem( emojiCode: json['id'] as String, diff --git a/test/example_data.dart b/test/example_data.dart index 79b92bdda8..3185fce269 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1098,6 +1098,7 @@ InitialSnapshot initialSnapshot({ int? serverTypingStartedExpiryPeriodMilliseconds, int? serverTypingStoppedWaitPeriodMilliseconds, int? serverTypingStartedWaitPeriodMilliseconds, + List? mutedUsers, Map? realmEmoji, List? recentPrivateConversations, List? savedSnippets, @@ -1134,6 +1135,7 @@ InitialSnapshot initialSnapshot({ serverTypingStoppedWaitPeriodMilliseconds ?? 5000, serverTypingStartedWaitPeriodMilliseconds: serverTypingStartedWaitPeriodMilliseconds ?? 10000, + mutedUsers: mutedUsers ?? [], realmEmoji: realmEmoji ?? {}, recentPrivateConversations: recentPrivateConversations ?? [], savedSnippets: savedSnippets ?? [], From 37a5948794e6146484873a4cb600044d10b9dcba Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 1 May 2025 22:18:27 +0430 Subject: [PATCH 189/290] api: Add muted_users event --- lib/api/model/events.dart | 19 +++++++++++++++++++ lib/api/model/events.g.dart | 15 +++++++++++++++ lib/api/model/model.dart | 2 +- lib/model/store.dart | 4 ++++ test/example_data.dart | 5 +++++ 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 62789333e1..2904173e81 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -62,6 +62,7 @@ sealed class Event { } // case 'muted_topics': … // TODO(#422) we ignore this feature on older servers case 'user_topic': return UserTopicEvent.fromJson(json); + case 'muted_users': return MutedUsersEvent.fromJson(json); case 'message': return MessageEvent.fromJson(json); case 'update_message': return UpdateMessageEvent.fromJson(json); case 'delete_message': return DeleteMessageEvent.fromJson(json); @@ -733,6 +734,24 @@ class UserTopicEvent extends Event { Map toJson() => _$UserTopicEventToJson(this); } +/// A Zulip event of type `muted_users`: https://zulip.com/api/get-events#muted_users +@JsonSerializable(fieldRename: FieldRename.snake) +class MutedUsersEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'muted_users'; + + final List mutedUsers; + + MutedUsersEvent({required super.id, required this.mutedUsers}); + + factory MutedUsersEvent.fromJson(Map json) => + _$MutedUsersEventFromJson(json); + + @override + Map toJson() => _$MutedUsersEventToJson(this); +} + /// A Zulip event of type `message`: https://zulip.com/api/get-events#message @JsonSerializable(fieldRename: FieldRename.snake) class MessageEvent extends Event { diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index ef8a214566..bb8119e8ed 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -468,6 +468,21 @@ const _$UserTopicVisibilityPolicyEnumMap = { UserTopicVisibilityPolicy.unknown: null, }; +MutedUsersEvent _$MutedUsersEventFromJson(Map json) => + MutedUsersEvent( + id: (json['id'] as num).toInt(), + mutedUsers: (json['muted_users'] as List) + .map((e) => MutedUserItem.fromJson(e as Map)) + .toList(), + ); + +Map _$MutedUsersEventToJson(MutedUsersEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'muted_users': instance.mutedUsers, + }; + MessageEvent _$MessageEventFromJson(Map json) => MessageEvent( id: (json['id'] as num).toInt(), message: Message.fromJson( diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 87a617dc4d..f284d336da 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -110,7 +110,7 @@ class CustomProfileFieldExternalAccountData { Map toJson() => _$CustomProfileFieldExternalAccountDataToJson(this); } -/// An item in the [InitialSnapshot.mutedUsers]. +/// An item in the [InitialSnapshot.mutedUsers] or [MutedUsersEvent]. /// /// For docs, search for "muted_users:" /// in . diff --git a/lib/model/store.dart b/lib/model/store.dart index 18a09e32ce..af1c41e857 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -949,6 +949,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor assert(debugLog("server event: reaction/${event.op}")); _messages.handleReactionEvent(event); + case MutedUsersEvent(): + // TODO handle + break; + case UnexpectedEvent(): assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better } diff --git a/test/example_data.dart b/test/example_data.dart index 3185fce269..2a2fd2bc1f 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -814,6 +814,11 @@ UserTopicEvent userTopicEvent( ); } +MutedUsersEvent mutedUsersEvent(List userIds) { + return MutedUsersEvent(id: 1, + mutedUsers: userIds.map((id) => MutedUserItem(id: id)).toList()); +} + MessageEvent messageEvent(Message message, {int? localMessageId}) => MessageEvent(id: 0, message: message, localMessageId: localMessageId?.toString()); From 425926301846d7c177ec0399b49d31c1ff40f4d3 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sat, 22 Feb 2025 21:44:16 +0430 Subject: [PATCH 190/290] user: Add UserStore.isUserMuted, with event updates Co-authored-by: Chris Bobbe --- lib/model/store.dart | 9 +++++++-- lib/model/user.dart | 21 ++++++++++++++++++++- test/model/user_test.dart | 23 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index af1c41e857..5171807a8e 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -645,6 +645,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor @override Iterable get allUsers => _users.allUsers; + @override + bool isUserMuted(int userId, {MutedUsersEvent? event}) => + _users.isUserMuted(userId, event: event); + final UserStoreImpl _users; final TypingStatus typingStatus; @@ -950,8 +954,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor _messages.handleReactionEvent(event); case MutedUsersEvent(): - // TODO handle - break; + assert(debugLog("server event: muted_users")); + _users.handleMutedUsersEvent(event); + notifyListeners(); case UnexpectedEvent(): assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better diff --git a/lib/model/user.dart b/lib/model/user.dart index 05ab2747df..f5079bfd31 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -66,6 +66,12 @@ mixin UserStore on PerAccountStoreBase { return getUser(message.senderId)?.fullName ?? message.senderFullName; } + + /// Whether the user with [userId] is muted by the self-user. + /// + /// Looks for [userId] in a private [Set], + /// or in [event.mutedUsers] instead if event is non-null. + bool isUserMuted(int userId, {MutedUsersEvent? event}); } /// The implementation of [UserStore] that does the work. @@ -81,7 +87,8 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { initialSnapshot.realmUsers .followedBy(initialSnapshot.realmNonActiveUsers) .followedBy(initialSnapshot.crossRealmBots) - .map((user) => MapEntry(user.userId, user))); + .map((user) => MapEntry(user.userId, user))), + _mutedUsers = Set.from(initialSnapshot.mutedUsers.map((item) => item.id)); final Map _users; @@ -91,6 +98,13 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { @override Iterable get allUsers => _users.values; + final Set _mutedUsers; + + @override + bool isUserMuted(int userId, {MutedUsersEvent? event}) { + return (event?.mutedUsers.map((item) => item.id) ?? _mutedUsers).contains(userId); + } + void handleRealmUserEvent(RealmUserEvent event) { switch (event) { case RealmUserAddEvent(): @@ -129,4 +143,9 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { } } } + + void handleMutedUsersEvent(MutedUsersEvent event) { + _mutedUsers.clear(); + _mutedUsers.addAll(event.mutedUsers.map((item) => item.id)); + } } diff --git a/test/model/user_test.dart b/test/model/user_test.dart index 63ac1589c7..27b07c129d 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -79,4 +79,27 @@ void main() { check(getUser()).deliveryEmail.equals('c@mail.example'); }); }); + + testWidgets('MutedUsersEvent', (tester) async { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUsers: [user1, user2, user3], + mutedUsers: [MutedUserItem(id: 2), MutedUserItem(id: 1)])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isFalse(); + + await store.handleEvent(eg.mutedUsersEvent([2, 1, 3])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + + await store.handleEvent(eg.mutedUsersEvent([2, 3])); + check(store.isUserMuted(1)).isFalse(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + }); } From adaa571165dfe4578baf222827563144f9eeaee9 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 25 Feb 2025 22:05:26 +0430 Subject: [PATCH 191/290] icons: Add "person", "eye", and "eye_off" icons Also renamed "user" to "two_person" to make it consistent with other icons of the same purpose. Icon resources: - Zulip Mobile Figma File: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5968-237884&t=dku3J5Fv2dmWo7ht-0 - Zulip Web Figma File: https://www.figma.com/design/jbNOiBWvbtLuHaiTj4CW0G/Zulip-Web-App?node-id=7752-46450&t=VkypsIaTZqSSptcS-0 https://www.figma.com/design/jbNOiBWvbtLuHaiTj4CW0G/Zulip-Web-App?node-id=7352-981&t=VkypsIaTZqSSptcS-0 --- assets/icons/ZulipIcons.ttf | Bin 14968 -> 15748 bytes assets/icons/eye.svg | 3 + assets/icons/eye_off.svg | 3 + assets/icons/person.svg | 10 +++ assets/icons/{user.svg => two_person.svg} | 0 lib/widgets/home.dart | 4 +- lib/widgets/icons.dart | 73 ++++++++++-------- lib/widgets/inbox.dart | 2 +- lib/widgets/message_list.dart | 2 +- test/widgets/home_test.dart | 2 +- test/widgets/message_list_test.dart | 2 +- test/widgets/new_dm_sheet_test.dart | 2 +- .../widgets/recent_dm_conversations_test.dart | 2 +- 13 files changed, 65 insertions(+), 40 deletions(-) create mode 100644 assets/icons/eye.svg create mode 100644 assets/icons/eye_off.svg create mode 100644 assets/icons/person.svg rename assets/icons/{user.svg => two_person.svg} (100%) diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 5df791a6c94a92bc57f4d26323f8f5550914fe91..ea32825d15deb9c293d8a6c565e18c7dd74f61a7 100644 GIT binary patch delta 2720 zcmb7GYfM|`8GgTW>~kDrb8&1ONN@nh7ffovKAdxG@ReXYkmjO%3pr{W9Z%m)Hy8Jm}MN} zJHWoVh1ILS{PfEQL{cZt*neU1Ols!-ed}E!#~+~ZorRgJOY96&vAGNL&Wke(70*wy zF(Uo@M1r=oxUw2pwf>nX`6tZZ-nip;j@KeU6hF;Ad1&LW18m&zvDvDnotkJLy-mNN z&zPI#*)8@l`&V4Q=oYO#B9*$CKo)Hu%lvqs0tpox<1BP^>W9uCMvt%d zQI$I?;_w_4qe%7kj5}?oAdcPxzB=4;XPjgkv^XUmT|34)aE- zH46VK0vsU^v^nKGEP04N@>Wrm7>?!@;m%`F;AD2uj{mN2mq13Q!q7QPPtzo2Hn!F!0>Va(r);tf~s{;QakV(RJ7;|2pEIe7E6{xF4YBBMKNFrtn-Z~uhV#F!M zjem%3Rh|J}M-}fBoabuVD&o^I-rnT|cA#|I^Z(U;T+@M$;+ln8rgZpz)UXY;Vs4)q zuR?7Eu@oR15e=_uy_Xfa`M}La8oqu0;k6nhA`(ld(EtkqQfo732#$=X9hq_2eJAJuezK3ct2{* zeb7o^KMVaPp6yy*`L(WJ=`2X(X~YhdB0e@2;kL1E&9DJu%X-C#N;V@eMFjD-aad9< z;;+7ZXuKtzGaBUPzK3wg|Gq$+e6%0Zj!)J@9JqzK9eEP)l<7RJ-5W5*WdDE)0(l&$ zf#4AOBm)u$KxhO(@XQ?qy)3AWq7H*-`jYX6e0oP8?$-c3OE`OGH+3zfo)<$ zSBeMY+2PT`;8@9c(RR)FktA0goS66x{>I-JP&R>&D$SU{2bG>Rk%v5EA_sZaga%nL zk%XKzF$8(eL>h9=LH54G9H!%o#)dbJbnu*fpustH|yVpm=L0tCNfjZDV zRDy$7#5n5+R9}}mrI(~1*wVIZw!1qNd)U5i|GZ(T;Uh=T@r>h+Q+IAPh8s^b{<|sI z{Iixq%WJNP>zl5vows&=>OSfIyXW=)|EYJL@QLsy37Zz@vb|~nAre0kPvY`+;Y*A1 zuOW- zgd>sg#_8#eaL*sP$(}vjZX$lFW-)gvj{CyNe<)tT=PHQXF#+-+H7J8-Jl5)QOI}Vm z%KY7uxrSielHAC|P$O1@p(=Q*8k7*68uPMNx75ve_;8?Vb)`Wi!-RKpmN6~o3hq3% zEWely4ttaj*7hpxh5b+U-~Lxh5JjP_m^A|9!P-m^TT_7vCudS_RpXS8%&D{ESLaaz-+Efi7t%2+Pl>rdt0nezC2p3?P-2E!5`PrZ{% z`Fqp3vE!xDp2)0ewpi9Se;X`2HeXTt*=A3Cifz6(I4R(tutxpc5*uUV?2z$>{zHdY uS*yrEd2x2uIFxu*mX<1)Ru(TBzfU~tvadeBSgy~d%NG|euU3p)Quz<=pS*tn delta 1969 zcmb7FT})e59RJ_;wzu@dmX9H9x}mVS;iSE7Z*OUPX(^?!7q$$y5L1D|U`#3CCt0-~FF+ z{@)+>^u=e(MV$m9a?v@mQlM|}M0(_}(VIj&nI_M9cyf*kdk?lOBM#l^Jxf5^S zew|2i;4F7~dU^7#4`2F`$XzBnxIQ(JKehPFl}{l16A+ogfkP1cv0ew-rV7QS&yL*r z8@kL#%k=EHmfzTSEe8XqAh24|18?g^69Cr-DSvB_fMDH#=Ya+41Y6 zMCvB?uU=Sh_4Jz=Ad8yb)%_PXWuf2LD@^Lv8|0-ndY`_gTT~Vf2xG!U;j(bsGGje% z9kzaI{hn;(rVh%|Fcrx{J__KGDM%rTkV;YN#412BicE2^|Dv3+jCc_$t}bwMr! zy%_X$6=6C5r4TkvDF1JKHG^%a5o)*mEA~6fj-k>LEKBa&2-HoZGwtINvQQv zGn5pRNalGpz&$H^8YV-?AVu|%W}zja$#UOIyHH?1T(jab<9_g&`$np{dtR-kgf{u^j2r`b7{RpNi^$^x6aF~88 z(oi82hBMI&DFAy7NMoAL(>rvD^vm=uZJ{bg@?Icb&2fQ^>Ce^d4l$dRm!QHeM1+><6E9&JQljw-T<0 zH-d%kMV74Vz2>mlF*R(KSpVB`I)Em5p{3!e3}=Y+o$>BNOgjal*t4AP=cPCEqjwg(xlFwg;HTL@x39{8;P$%x9GQ3^I-iZQP@QlzI zTK=QMSdm)g5WFYhER5Kq=2a0x!6I<2m?g_1VhvfP(SRVnfL`N<^f=7h;WfVp+R3i4 z8jUBQ6-g;_CL7Q?(>=kyUhtn;l{~teMPy~yBm4CMBOD>nJcl22j3Ws;&d~+RbQ!|H z1cwSb$$`6`PIJUTr#KRzXLR-gV!)Fe-JsJPxT~qakpi9JP(WunWY9Sd){dt*vY=-< z0-*C88fcM2>%?M#BMrL9(F6K42mT;viGx*WnWMM#va{V{Tz9wXYqoEsoODszavZP8 z)_mY>aIQFib7fr5yEfb>-M2iAo^{Vn?>V2tm-B7b9;;oc8>)M^zN!9d!|?xq;NF}h z%XP~d=7j`T={ZlqS^B7X$Xoj2P|jUCqUMBBb0i>?-ir=d@Sn96d&77${;@HTc*Xc3 YG3K@}%oG-i6XL@B#98B*7Pi9XKO#ON;s5{u diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 0000000000..c5cc095bbe --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/eye_off.svg b/assets/icons/eye_off.svg new file mode 100644 index 0000000000..cc2c3587d7 --- /dev/null +++ b/assets/icons/eye_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/person.svg b/assets/icons/person.svg new file mode 100644 index 0000000000..6a35686e46 --- /dev/null +++ b/assets/icons/person.svg @@ -0,0 +1,10 @@ + + + diff --git a/assets/icons/user.svg b/assets/icons/two_person.svg similarity index 100% rename from assets/icons/user.svg rename to assets/icons/two_person.svg diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 404472f7d0..4e70bb1e76 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -111,7 +111,7 @@ class _HomePageState extends State { narrow: const CombinedFeedNarrow()))), button(_HomePageTab.channels, ZulipIcons.hash_italic), // TODO(#1094): Users - button(_HomePageTab.directMessages, ZulipIcons.user), + button(_HomePageTab.directMessages, ZulipIcons.two_person), _NavigationBarButton( icon: ZulipIcons.menu, selected: false, onPressed: () => _showMainMenu(context, tabNotifier: _tab)), @@ -549,7 +549,7 @@ class _DirectMessagesButton extends _NavigationBarMenuButton { const _DirectMessagesButton({required super.tabNotifier}); @override - IconData get icon => ZulipIcons.user; + IconData get icon => ZulipIcons.two_person; @override String label(ZulipLocalizations zulipLocalizations) { diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 8f31630de2..9df1289101 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -72,95 +72,104 @@ abstract final class ZulipIcons { /// The Zulip custom icon "edit". static const IconData edit = IconData(0xf110, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "eye". + static const IconData eye = IconData(0xf111, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "eye_off". + static const IconData eye_off = IconData(0xf112, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf122, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "person". + static const IconData person = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf12f, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf12d, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "two_person". + static const IconData two_person = IconData(0xf130, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12e, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "unmute". + static const IconData unmute = IconData(0xf131, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 702e4135bf..cd1822bbac 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -327,7 +327,7 @@ class _AllDmsHeaderItem extends _HeaderItem { @override String title(ZulipLocalizations zulipLocalizations) => zulipLocalizations.recentDmConversationsSectionHeader; - @override IconData get icon => ZulipIcons.user; + @override IconData get icon => ZulipIcons.two_person; // TODO(design) check if this is the right variable for these @override Color collapsedIconColor(context) => DesignVariables.of(context).labelMenuButton; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index a34a25bb37..14c33ad5fd 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1389,7 +1389,7 @@ class DmRecipientHeader extends StatelessWidget { child: Icon( color: designVariables.title, size: 16, - ZulipIcons.user)), + ZulipIcons.two_person)), Expanded( child: Text(title, style: recipientHeaderTextStyle(context), diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index efad9e6b9a..1b5c8ad8b5 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -125,7 +125,7 @@ void main () { of: find.byType(ZulipAppBar), matching: find.text('Channels'))).findsOne(); - await tester.tap(find.byIcon(ZulipIcons.user)); + await tester.tap(find.byIcon(ZulipIcons.two_person)); await tester.pump(); check(find.descendant( of: find.byType(ZulipAppBar), diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 8ead103d68..f048bf437d 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1431,7 +1431,7 @@ void main() { final textSpan = tester.renderObject(find.text( zulipLocalizations.messageListGroupYouAndOthers( zulipLocalizations.unknownUserName))).text; - final icon = tester.widget(find.byIcon(ZulipIcons.user)); + final icon = tester.widget(find.byIcon(ZulipIcons.two_person)); check(textSpan).style.isNotNull().color.isNotNull().isSameColorAs(icon.color!); }); }); diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index f1f72d272d..65d92f72a2 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -38,7 +38,7 @@ Future setupSheet(WidgetTester tester, { child: const HomePage())); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(ZulipIcons.user)); + await tester.tap(find.byIcon(ZulipIcons.two_person)); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(GestureDetector, 'New DM')); diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 6bd01b40c8..b7307ef6f2 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -58,7 +58,7 @@ Future setupPage(WidgetTester tester, { // Switch to direct messages tab. await tester.tap(find.descendant( of: find.byType(Center), - matching: find.byIcon(ZulipIcons.user))); + matching: find.byIcon(ZulipIcons.two_person))); await tester.pump(); } From efdfbeabac7310a15edab44005775a646b929b7a Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Fri, 13 Jun 2025 07:01:52 +0200 Subject: [PATCH 192/290] l10n: Update translations from Weblate. --- assets/l10n/app_de.arb | 4 + assets/l10n/app_it.arb | 1 + assets/l10n/app_ru.arb | 16 + assets/l10n/app_sl.arb | 1136 ++++++++++++++++ assets/l10n/app_uk.arb | 140 +- assets/l10n/app_zh_Hans_CN.arb | 1157 ++++++++++++++++- assets/l10n/app_zh_Hant_TW.arb | 161 ++- lib/generated/l10n/zulip_localizations.dart | 10 + .../l10n/zulip_localizations_de.dart | 2 +- .../l10n/zulip_localizations_it.dart | 839 ++++++++++++ .../l10n/zulip_localizations_ru.dart | 8 +- .../l10n/zulip_localizations_sl.dart | 862 ++++++++++++ .../l10n/zulip_localizations_uk.dart | 74 +- .../l10n/zulip_localizations_zh.dart | 906 +++++++++++++ 14 files changed, 5270 insertions(+), 46 deletions(-) create mode 100644 assets/l10n/app_it.arb create mode 100644 assets/l10n/app_sl.arb create mode 100644 lib/generated/l10n/zulip_localizations_it.dart create mode 100644 lib/generated/l10n/zulip_localizations_sl.dart diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb index d1def3c893..b4854e64c5 100644 --- a/assets/l10n/app_de.arb +++ b/assets/l10n/app_de.arb @@ -18,5 +18,9 @@ "switchAccountButton": "Konto wechseln", "@switchAccountButton": { "description": "Label for main-menu button leading to the choose-account page." + }, + "aboutPageOpenSourceLicenses": "Open-Source-Lizenzen", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" } } diff --git a/assets/l10n/app_it.arb b/assets/l10n/app_it.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_it.arb @@ -0,0 +1 @@ +{} diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index eb79229d04..13912d380a 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1116,5 +1116,21 @@ "errorNotificationOpenAccountNotFound": "Учетная запись, связанная с этим уведомлением, не найдена.", "@errorNotificationOpenAccountNotFound": { "description": "Error message when the account associated with the notification could not be found" + }, + "channelsEmptyPlaceholder": "Вы еще не подписаны ни на один канал.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "recentDmConversationsEmptyPlaceholder": "У вас пока нет личных сообщений! Почему бы не начать беседу?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "newDmSheetComposeButtonLabel": "Написать", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "inboxEmptyPlaceholder": "Нет непрочитанных входящих сообщений. Используйте кнопки ниже для просмотра объединенной ленты или списка каналов.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." } } diff --git a/assets/l10n/app_sl.arb b/assets/l10n/app_sl.arb new file mode 100644 index 0000000000..f539084ab7 --- /dev/null +++ b/assets/l10n/app_sl.arb @@ -0,0 +1,1136 @@ +{ + "aboutPageTitle": "O Zulipu", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "permissionsDeniedCameraAccess": "Za nalaganje slik v nastavitvah omogočite Zulipu dostop do kamere.", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "actionSheetOptionFollowTopic": "Sledi temi", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "errorFailedToUploadFileTitle": "Nalaganje datoteke ni uspelo: {filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "composeBoxBannerButtonCancel": "Prekliči", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonSave": "Shrani", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "composeBoxEnterTopicOrSkipHintText": "Vnesite temo (ali pustite prazno za »{defaultTopicName}«)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "loginFormSubmitLabel": "Prijava", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "userRoleModerator": "Moderator", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "recentDmConversationsSectionHeader": "Neposredna sporočila", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "wildcardMentionEveryone": "vsi", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionChannel": "kanal", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "themeSettingDark": "Temna", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "errorCouldNotFetchMessageSource": "Ni bilo mogoče pridobiti vira sporočila.", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "markAsReadComplete": "Označeno je {num, plural, =1{1 sporočilo} one{2 sporočili} few{{num} sporočila} other{{num} sporočil}} kot prebrano.", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "successLinkCopied": "Povezava je bila kopirana", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "permissionsDeniedReadExternalStorage": "Za nalaganje datotek v nastavitvah omogočite Zulipu dostop do shrambe datotek.", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "actionSheetOptionUnfollowTopic": "Prenehaj slediti temi", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "actionSheetOptionResolveTopic": "Označi kot razrešeno", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "actionSheetOptionUnresolveTopic": "Označi kot nerazrešeno", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Neuspela označitev teme kot razrešene", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "Neuspela označitev teme kot nerazrešene", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "actionSheetOptionCopyMessageText": "Kopiraj besedilo sporočila", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionCopyMessageLink": "Kopiraj povezavo do sporočila", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "actionSheetOptionMarkAsUnread": "Od tu naprej označi kot neprebrano", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "actionSheetOptionShare": "Deli", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionQuoteAndReply": "Citiraj in odgovori", + "@actionSheetOptionQuoteAndReply": { + "description": "Label for Quote and reply button on action sheet." + }, + "actionSheetOptionStarMessage": "Označi sporočilo z zvezdico", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionUnstarMessage": "Odstrani zvezdico s sporočila", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "actionSheetOptionEditMessage": "Uredi sporočilo", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "Označi temo kot prebrano", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "errorWebAuthOperationalErrorTitle": "Nekaj je šlo narobe", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorWebAuthOperationalError": "Prišlo je do nepričakovane napake.", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorAccountLoggedInTitle": "Račun je že prijavljen", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "errorAccountLoggedIn": "Račun {email} na {server} je že na vašem seznamu računov.", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "errorCopyingFailed": "Kopiranje ni uspelo", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "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" + } + } + }, + "errorLoginInvalidInputTitle": "Neveljaven vnos", + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "errorLoginFailedTitle": "Prijava ni uspela", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorMessageNotSent": "Pošiljanje sporočila ni uspelo", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "errorMessageEditNotSaved": "Sporočilo ni bilo shranjeno", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorLoginCouldNotConnect": "Ni se mogoče povezati s strežnikom:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorCouldNotConnectTitle": "Povezave ni bilo mogoče vzpostaviti", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorMessageDoesNotSeemToExist": "Zdi se, da to sporočilo ne obstaja.", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "errorQuotationFailed": "Citiranje ni uspelo", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "errorServerMessage": "Strežnik je sporočil:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerDetails": "Napaka pri povezovanju z Zulipom na {serverUrl}. Poskusili bomo znova:\n\n{error}", + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "http://example.com/" + }, + "error": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerShort": "Napaka pri povezovanju z Zulipom. Poskušamo znova…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorCouldNotOpenLinkTitle": "Povezave ni mogoče odpreti", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorMuteTopicFailed": "Utišanje teme ni uspelo", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorCouldNotOpenLink": "Povezave ni bilo mogoče odpreti: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "errorUnmuteTopicFailed": "Preklic utišanja teme ni uspel", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorFollowTopicFailed": "Sledenje temi ni uspelo", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorUnfollowTopicFailed": "Prenehanje sledenja temi ni uspelo", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorSharingFailed": "Deljenje ni uspelo", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "errorStarMessageFailedTitle": "Sporočila ni bilo mogoče označiti z zvezdico", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnstarMessageFailedTitle": "Sporočilu ni bilo mogoče odstraniti zvezdice", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "errorCouldNotEditMessageTitle": "Sporočila ni mogoče urediti", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "errorBannerDeactivatedDmLabel": "Deaktiviranim uporabnikom ne morete pošiljati sporočil.", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "successMessageLinkCopied": "Povezava do sporočila je bila kopirana", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "errorBannerCannotPostInChannelLabel": "Nimate dovoljenja za objavljanje v tem kanalu.", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "composeBoxBannerLabelEditMessage": "Uredi sporočilo", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Urejanje sporočila ni mogoče", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "Urejanje je že v teku. Počakajte, da se konča.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "SHRANJEVANJE SPREMEMB…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "UREJANJE NI SHRANJENO", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogConfirmButton": "Zavrzi", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxAttachFilesTooltip": "Pripni datoteke", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "composeBoxAttachMediaTooltip": "Pripni fotografije ali videoposnetke", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "composeBoxAttachFromCameraTooltip": "Fotografiraj", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxGenericContentHint": "Vnesite sporočilo", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "newDmSheetComposeButtonLabel": "Napiši", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetScreenTitle": "Novo neposredno sporočilo", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "Novo neposredno sporočilo", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintEmpty": "Dodajte enega ali več uporabnikov", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetNoUsersFound": "Ni zadetkov med uporabniki", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "composeBoxDmContentHint": "Sporočilo @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "composeBoxGroupDmContentHint": "Skupinsko sporočilo", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxSelfDmContentHint": "Zapišite opombo zase", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxChannelContentHint": "Sporočilo {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "preparingEditMessageContentInput": "Pripravljanje…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxSendTooltip": "Pošlji", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "unknownChannelName": "(neznan kanal)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "composeBoxTopicHintText": "Tema", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "composeBoxUploadingFilename": "Nalaganje {filename}…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "composeBoxLoadingMessage": "(nalaganje sporočila {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "unknownUserName": "(neznan uporabnik)", + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "dmsWithYourselfPageTitle": "Neposredna sporočila s samim seboj", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "messageListGroupYouAndOthers": "Vi in {others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "dmsWithOthersPageTitle": "Neposredna sporočila z {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "contentValidationErrorTooLong": "Dolžina sporočila ne sme presegati 10000 znakov.", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "contentValidationErrorEmpty": "Ni vsebine za pošiljanje!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "contentValidationErrorUploadInProgress": "Počakajte, da se nalaganje konča.", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "dialogCancel": "Prekliči", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "dialogContinue": "Nadaljuj", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "dialogClose": "Zapri", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "errorDialogLearnMore": "Več o tem", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "errorDialogContinue": "V redu", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "errorDialogTitle": "Napaka", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "snackBarDetails": "Podrobnosti", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "lightboxCopyLinkTooltip": "Kopiraj povezavo", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "lightboxVideoCurrentPosition": "Trenutni položaj", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxVideoDuration": "Trajanje videa", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "loginPageTitle": "Prijava", + "@loginPageTitle": { + "description": "Title for login page." + }, + "loginMethodDivider": "ALI", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "loginAddAnAccountPageTitle": "Dodaj račun", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "signInWithFoo": "Prijava z {method}", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "loginServerUrlLabel": "URL strežnika Zulip", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginHidePassword": "Skrij geslo", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "loginEmailLabel": "E-poštni naslov", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "loginErrorMissingEmail": "Vnesite svoj e-poštni naslov.", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "loginPasswordLabel": "Geslo", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginErrorMissingPassword": "Vnesite svoje geslo.", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "loginUsernameLabel": "Uporabniško ime", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "loginErrorMissingUsername": "Vnesite svoje uporabniško ime.", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "topicValidationErrorTooLong": "Dolžina teme ne sme presegati 60 znakov.", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "topicValidationErrorMandatoryButEmpty": "Teme so v tej organizaciji obvezne.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "errorServerVersionUnsupportedMessage": "{url} uporablja strežnik Zulip {zulipVersion}, ki ni podprt. Najnižja podprta različica je strežnik Zulip {minSupportedZulipVersion}.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + }, + "zulipVersion": { + "type": "String", + "example": "3.2" + }, + "minSupportedZulipVersion": { + "type": "String", + "example": "4.0" + } + } + }, + "errorInvalidApiKeyMessage": "Vašega računa na {url} ni bilo mogoče overiti. Poskusite se znova prijaviti ali uporabite drug račun.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorInvalidResponse": "Strežnik je poslal neveljaven odgovor.", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "errorMalformedResponse": "Strežnik je poslal napačno oblikovan odgovor; stanje HTTP {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + } + } + }, + "errorMalformedResponseWithCause": "Strežnik je poslal napačno oblikovan odgovor; stanje HTTP {httpStatus}; {details}", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + }, + "details": { + "type": "String", + "example": "type 'Null' is not a subtype of type 'String' in type cast" + } + } + }, + "errorVideoPlayerFailed": "Videa ni mogoče predvajati.", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "serverUrlValidationErrorEmpty": "Vnesite URL.", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "serverUrlValidationErrorNoUseEmail": "Vnesite URL strežnika, ne vašega e-poštnega naslova.", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "serverUrlValidationErrorUnsupportedScheme": "URL strežnika se mora začeti s http:// ali https://.", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "markAllAsReadLabel": "Označi vsa sporočila kot prebrana", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "spoilerDefaultHeaderText": "Skrito", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAsReadInProgress": "Označevanje sporočil kot prebranih…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "errorMarkAsReadFailedTitle": "Označevanje kot prebrano ni uspelo", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "markAsUnreadInProgress": "Označevanje sporočil kot neprebranih…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "Označevanje kot neprebrano ni uspelo", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "today": "Danes", + "@today": { + "description": "Term to use to reference the current day." + }, + "yesterday": "Včeraj", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "userRoleOwner": "Lastnik", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleAdministrator": "Skrbnik", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "userRoleMember": "Član", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "userRoleGuest": "Gost", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "userRoleUnknown": "Neznano", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "inboxPageTitle": "Nabiralnik", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "recentDmConversationsPageTitle": "Neposredna sporočila", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "mentionsPageTitle": "Omembe", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "combinedFeedPageTitle": "Združen prikaz", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "starredMessagesPageTitle": "Sporočila z zvezdico", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "channelsPageTitle": "Kanali", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "channelsEmptyPlaceholder": "Niste še naročeni na noben kanal.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "mainMenuMyProfile": "Moj profil", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "topicsButtonLabel": "TEME", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "channelFeedButtonTooltip": "Sporočila kanala", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "notifGroupDmConversationLabel": "{senderFullName} vam in {numOthers, plural, =1{1 drugi osebi} other{{numOthers} drugim osebam}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "notifSelfUser": "Vi", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "reactedEmojiSelfUser": "Vi", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "onePersonTyping": "{typist} tipka…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "manyPeopleTyping": "Več oseb tipka…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "twoPeopleTyping": "{typist} in {otherTypist} tipkata…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + }, + "otherTypist": { + "type": "String", + "example": "Bob" + } + } + }, + "wildcardMentionAll": "vsi", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionStream": "tok", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "wildcardMentionTopic": "tema", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionChannelDescription": "Obvesti kanal", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "wildcardMentionStreamDescription": "Obvesti tok", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionAllDmDescription": "Obvesti prejemnike", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "wildcardMentionTopicDescription": "Obvesti udeležence teme", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "messageIsEditedLabel": "UREJENO", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageIsMovedLabel": "PREMAKNJENO", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageNotSentLabel": "SPOROČILO NI POSLANO", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "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" + } + } + }, + "themeSettingTitle": "TEMA", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingLight": "Svetla", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "themeSettingSystem": "Sistemska", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "openLinksWithInAppBrowser": "Odpri povezave v brskalniku znotraj aplikacije", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "pollWidgetQuestionMissing": "Brez vprašanja.", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "experimentalFeatureSettingsPageTitle": "Eksperimentalne funkcije", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "pollWidgetOptionsMissing": "Ta anketa še nima odgovorov.", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "experimentalFeatureSettingsWarning": "Te možnosti omogočajo funkcije, ki so še v razvoju in niso pripravljene. Morda ne bodo delovale in lahko povzročijo težave v drugih delih aplikacije.\n\nNamen teh nastavitev je eksperimentiranje za uporabnike, ki delajo na razvoju Zulipa.", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "errorNotificationOpenAccountNotFound": "Računa, povezanega s tem obvestilom, ni bilo mogoče najti.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "errorReactionAddingFailedTitle": "Reakcije ni bilo mogoče dodati", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "Reakcije ni bilo mogoče odstraniti", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "emojiReactionsMore": "več", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "emojiPickerSearchEmoji": "Iskanje emojijev", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "noEarlierMessages": "Ni starejših sporočil", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "mutedSender": "Utišan pošiljatelj", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "revealButtonLabel": "Prikaži sporočilo utišanega pošiljatelja", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Uporabnik je utišan", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "appVersionUnknownPlaceholder": "(...)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "scrollToBottomTooltip": "Premakni se na konec", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "recentDmConversationsEmptyPlaceholder": "Zaenkrat še nimate neposrednih sporočil! Zakaj ne bi začeli pogovora?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "errorFilesTooLarge": "{num, plural, =1{Datoteka presega} one{Dve datoteki presegata} few{{num} datoteke presegajo} other{{num} datotek presega}} omejitev velikosti strežnika ({maxFileUploadSizeMib} MiB) in {num, plural, =1{ne bo naložena} one{ne bosta naloženi} few{ne bodo naložene} other{ne bodo naložene}}:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "2" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB" + } + } + }, + "inboxEmptyPlaceholder": "V vašem nabiralniku ni neprebranih sporočil. Uporabite spodnje gumbe za ogled združenega prikaza ali seznama kanalov.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "successMessageTextCopied": "Besedilo sporočila je bilo kopirano", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "contentValidationErrorQuoteAndReplyInProgress": "Počakajte, da se citat zaključi.", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "errorNetworkRequestFailed": "Omrežna zahteva je spodletela", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "aboutPageAppVersion": "Različica aplikacije", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageOpenSourceLicenses": "Odprtokodne licence", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "aboutPageTapToView": "Dotaknite se za ogled", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "chooseAccountPageTitle": "Izberite račun", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "settingsPageTitle": "Nastavitve", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "switchAccountButton": "Preklopi račun", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "tryAnotherAccountMessage": "Nalaganje vašega računa na {url} traja dlje kot običajno.", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "tryAnotherAccountButton": "Poskusite z drugim računom", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "chooseAccountPageLogOutButton": "Odjava", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "logOutConfirmationDialogTitle": "Se želite odjaviti?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogMessage": "Če boste ta račun želeli uporabljati v prihodnje, boste morali znova vnesti URL svoje organizacije in podatke za prijavo.", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "Odjavi se", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "chooseAccountButtonAddAnAccount": "Dodaj račun", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "profileButtonSendDirectMessage": "Pošlji neposredno sporočilo", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "errorCouldNotShowUserProfile": "Uporabniškega profila ni mogoče prikazati.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "permissionsNeededTitle": "Potrebna so dovoljenja", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "permissionsNeededOpenSettings": "Odpri nastavitve", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "actionSheetOptionMarkChannelAsRead": "Označi kanal kot prebran", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionListOfTopics": "Seznam tem", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionMuteTopic": "Utišaj temo", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "Prekliči utišanje teme", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "errorFilesTooLargeTitle": "\"{num, plural, =1{Datoteka je prevelika} one{Dve datoteki sta preveliki} few{{num} datoteke so prevelike} other{{num} datotek je prevelikih}}\"", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "markAsUnreadComplete": "{num, plural, =1{Označeno je 1 sporočilo kot neprebrano} one{Označeni sta 2 sporočili kot neprebrani} few{Označena so {num} sporočila kot neprebrana} other{Označeno je {num} sporočil kot neprebranih}}.", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorHandlingEventTitle": "Napaka pri obravnavi posodobitve. Povezujemo se znova…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "actionSheetOptionHideMutedMessage": "Znova skrij utišano sporočilo", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "errorHandlingEventDetails": "Napaka pri obravnavi posodobitve iz strežnika {serverUrl}; poskusili bomo znova.\n\nNapaka: {error}\n\nDogodek: {event}", + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "https://chat.example.com" + }, + "error": { + "type": "String", + "example": "Unexpected null value" + }, + "event": { + "type": "String", + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + } + } + }, + "discardDraftConfirmationDialogTitle": "Želite zavreči sporočilo, ki ga pišete?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftForEditConfirmationDialogMessage": "Ko urejate sporočilo, se prejšnja vsebina polja za pisanje zavrže.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "newDmSheetSearchHintSomeSelected": "Dodajte še enega uporabnika…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "unpinnedSubscriptionsLabel": "Nepripeto", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "messageListGroupYouWithYourself": "Sporočila sebi", + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "errorRequestFailed": "Omrežna zahteva je spodletela: Stanje HTTP {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "serverUrlValidationErrorInvalidUrl": "Vnesite veljaven URL.", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "errorNotificationOpenTitle": "Obvestila ni bilo mogoče odpreti", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "pinnedSubscriptionsLabel": "Pripeto", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + } +} diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb index b2f60e2453..0e6e5c452e 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -473,7 +473,7 @@ "@errorWebAuthOperationalError": { "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." }, - "errorCouldNotFetchMessageSource": "Не вдалося отримати джерело повідомлення", + "errorCouldNotFetchMessageSource": "Не вдалося отримати джерело повідомлення.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -619,7 +619,7 @@ } } }, - "errorInvalidResponse": "Сервер надіслав недійсну відповідь", + "errorInvalidResponse": "Сервер надіслав недійсну відповідь.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, @@ -637,7 +637,7 @@ } } }, - "errorVideoPlayerFailed": "Неможливо відтворити відео", + "errorVideoPlayerFailed": "Неможливо відтворити відео.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, @@ -998,5 +998,139 @@ "emojiReactionsMore": "більше", "@emojiReactionsMore": { "description": "Label for a button opening the emoji picker." + }, + "newDmSheetSearchHintEmpty": "Додати користувачів", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetSearchHintSomeSelected": "Додати ще…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "newDmSheetComposeButtonLabel": "Написати", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "channelsEmptyPlaceholder": "Ви ще не підписані на жодний канал.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "recentDmConversationsEmptyPlaceholder": "У вас поки що немає особистих повідомлень! Чому б не розпочати бесіду?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "inboxEmptyPlaceholder": "Немає непрочитаних вхідних повідомлень. Використовуйте кнопки знизу для перегляду обʼєднаної стрічки або списку каналів.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "actionSheetOptionListOfTopics": "Список тем", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "composeBoxBannerButtonCancel": "Відміна", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Неможливо редагувати повідомлення", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "topicsButtonLabel": "ТЕМИ", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "actionSheetOptionHideMutedMessage": "Сховати заглушене повідомлення", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "composeBoxBannerLabelEditMessage": "Редагування повідомлення", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "discardDraftForEditConfirmationDialogMessage": "При редагуванні повідомлення, текст з поля для редагування видаляється.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "newDmSheetScreenTitle": "Нове особисте повідомлення", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "Нове особисте повідомлення", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetNoUsersFound": "Користувачі не знайдені", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "revealButtonLabel": "Показати повідомлення заглушеного відправника", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "messageNotSentLabel": "ПОВІДОМЛЕННЯ НЕ ВІДПРАВЛЕНО", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "errorNotificationOpenAccountNotFound": "Обліковий запис, звʼязаний з цим сповіщенням, не знайдений.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "mutedSender": "Заглушений відправник", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "actionSheetOptionEditMessage": "Редагувати повідомлення", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorMessageEditNotSaved": "Повідомлення не збережено", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorCouldNotEditMessageTitle": "Не вдалося редагувати повідомлення", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxBannerButtonSave": "Зберегти", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressMessage": "Редагування уже виконується. Дочекайтеся його завершення.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "ЗБЕРЕЖЕННЯ ПРАВОК…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "ПРАВКИ НЕ ЗБЕРЕЖЕНІ", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Відмовитися від написаного повідомлення?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Скинути", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "preparingEditMessageContentInput": "Підготовка…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxEnterTopicOrSkipHintText": "Вкажіть тему (або залиште “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "mutedUser": "Заглушений користувач", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." } } diff --git a/assets/l10n/app_zh_Hans_CN.arb b/assets/l10n/app_zh_Hans_CN.arb index 9766804e42..ce32a1a36a 100644 --- a/assets/l10n/app_zh_Hans_CN.arb +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -1,3 +1,1158 @@ { - "settingsPageTitle": "设置" + "settingsPageTitle": "设置", + "@settingsPageTitle": {}, + "actionSheetOptionResolveTopic": "标记为已解决", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "aboutPageTitle": "关于Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "aboutPageAppVersion": "应用程序版本", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageOpenSourceLicenses": "开源许可", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "chooseAccountPageTitle": "选择账号", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "switchAccountButton": "切换账号", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "tryAnotherAccountButton": "尝试另一个账号", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "chooseAccountPageLogOutButton": "登出", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "logOutConfirmationDialogTitle": "登出?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "chooseAccountButtonAddAnAccount": "添加一个账号", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "profileButtonSendDirectMessage": "发送私信", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "errorCouldNotShowUserProfile": "无法显示用户个人资料。", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "permissionsNeededOpenSettings": "打开设置", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "actionSheetOptionMarkChannelAsRead": "标记频道为已读", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionListOfTopics": "话题列表", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionFollowTopic": "关注话题", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "actionSheetOptionUnfollowTopic": "取消关注话题", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "aboutPageTapToView": "查看更多", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "tryAnotherAccountMessage": "您在 {url} 的账号加载时间过长。", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "logOutConfirmationDialogMessage": "下次登入此账号时,您将需要重新输入组织网址和账号信息。", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "登出", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "permissionsNeededTitle": "需要额外权限", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "permissionsDeniedCameraAccess": "上传图片前,请在设置授予 Zulip 相应的权限。", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "permissionsDeniedReadExternalStorage": "上传文件前,请在设置授予 Zulip 相应的权限。", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "newDmSheetComposeButtonLabel": "撰写消息", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "composeBoxChannelContentHint": "发送消息到 {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "preparingEditMessageContentInput": "准备编辑消息…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxSendTooltip": "发送", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "unknownChannelName": "(未知频道)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "composeBoxLoadingMessage": "(加载消息 {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "unknownUserName": "(未知用户)", + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "dmsWithOthersPageTitle": "与{others}的私信", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "messageListGroupYouWithYourself": "与自己的私信", + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "contentValidationErrorTooLong": "消息的长度不能超过10000个字符。", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "errorDialogLearnMore": "更多信息", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "errorDialogContinue": "好的", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "errorDialogTitle": "错误", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "lightboxCopyLinkTooltip": "复制链接", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "lightboxVideoCurrentPosition": "当前进度", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxVideoDuration": "视频时长", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "loginFormSubmitLabel": "登入", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "loginMethodDivider": "或", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "signInWithFoo": "使用{method}登入", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "loginAddAnAccountPageTitle": "添加账号", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "loginServerUrlLabel": "Zulip 服务器网址", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginHidePassword": "隐藏密码", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "topicValidationErrorMandatoryButEmpty": "话题在该组织为必填项。", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "errorInvalidApiKeyMessage": "您在 {url} 的账号无法被登入。请重试或者使用另外的账号。", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorInvalidResponse": "服务器的回复不合法。", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "errorNetworkRequestFailed": "网络请求失败", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "errorMalformedResponse": "服务器的回复不合法;HTTP 状态码 {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + } + } + }, + "errorMalformedResponseWithCause": "服务器的回复不合法;HTTP 状态码 {httpStatus}; {details}", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + }, + "details": { + "type": "String", + "example": "type 'Null' is not a subtype of type 'String' in type cast" + } + } + }, + "serverUrlValidationErrorInvalidUrl": "请输入正确的网址。", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "serverUrlValidationErrorNoUseEmail": "请输入服务器网址,而不是您的电子邮件。", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "spoilerDefaultHeaderText": "剧透", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAllAsReadLabel": "将所有消息标为已读", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "markAsReadComplete": "已将 {num, plural, other{{num} 条消息}}标为已读。", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "markAsUnreadInProgress": "正在将消息标为未读…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "未能将消息标为未读", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "userRoleAdministrator": "管理员", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "userRoleModerator": "版主", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "userRoleMember": "成员", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "recentDmConversationsEmptyPlaceholder": "您还没有任何私信消息!何不开启一个新对话?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "channelsEmptyPlaceholder": "您还没有订阅任何频道。", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "channelFeedButtonTooltip": "频道订阅", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "unpinnedSubscriptionsLabel": "未置顶", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "notifGroupDmConversationLabel": "{senderFullName}向你和其他 {numOthers, plural, other{{numOthers} 个用户}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "wildcardMentionChannel": "频道", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionEveryone": "所有人", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionStream": "频道", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "wildcardMentionTopic": "话题", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionTopicDescription": "通知话题", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "messageNotSentLabel": "消息未发送", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageIsEditedLabel": "已编辑", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingDark": "深色", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingLight": "浅色", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "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" + } + } + }, + "pollWidgetOptionsMissing": "该投票还没有任何选项。", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "experimentalFeatureSettingsWarning": "以下选项启用了一些正在开发中的功能。它们可能不能正常使用,或造成一些其他的问题。\n\n这些选项能够帮助开发者更好的试验这些功能。", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "initialAnchorSettingDescription": "您可以将消息的起始位置设置为第一条未读消息或者最新消息。", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingTitle": "设置消息起始位置于", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "最新消息", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "在单个话题或私信中,从第一条未读消息开始;在其他情况下,从最新消息开始", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "第一条未读消息", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "actionSheetOptionCopyMessageLink": "复制消息链接", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "errorConnectingToServerDetails": "未能连接到在 {serverUrl} 的 Zulip 服务器。即将重连:\n\n{error}", + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "http://example.com/" + }, + "error": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorAccountLoggedIn": "在 {server} 的账号 {email} 已经在您的账号列表了。", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "errorServerVersionUnsupportedMessage": "{url} 运行的 Zulip 服务器版本 {zulipVersion} 过低。该客户端只支持 {minSupportedZulipVersion} 及以后的服务器版本。", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + }, + "zulipVersion": { + "type": "String", + "example": "3.2" + }, + "minSupportedZulipVersion": { + "type": "String", + "example": "4.0" + } + } + }, + "errorHandlingEventDetails": "处理来自 {serverUrl} 的 Zulip 事件时发生了一些问题。即将重连。\n\n错误:{error}\n\n事件:{event}", + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "https://chat.example.com" + }, + "error": { + "type": "String", + "example": "Unexpected null value" + }, + "event": { + "type": "String", + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + } + } + }, + "editAlreadyInProgressTitle": "未能编辑消息", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "errorServerMessage": "服务器:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "loginErrorMissingUsername": "请输入用户名。", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "successMessageTextCopied": "已复制消息文本", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "errorBannerDeactivatedDmLabel": "您不能向被停用的用户发送消息。", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "noEarlierMessages": "没有更早的消息了", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "discardDraftForOutboxConfirmationDialogMessage": "当您恢复未能发送的消息时,文本框已有的内容将会被清空。", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "loginPageTitle": "登入", + "@loginPageTitle": { + "description": "Title for login page." + }, + "loginEmailLabel": "电子邮箱地址", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "topicValidationErrorTooLong": "话题长度不应该超过 60 个字符。", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "userRoleUnknown": "未知", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "markAsReadInProgress": "正在将消息标为已读…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "onePersonTyping": "{typist}正在输入…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "inboxPageTitle": "收件箱", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "openLinksWithInAppBrowser": "使用内置浏览器打开链接", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "inboxEmptyPlaceholder": "你的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "themeSettingSystem": "系统", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "experimentalFeatureSettingsPageTitle": "实验功能", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "wildcardMentionChannelDescription": "通知频道", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "actionSheetOptionMuteTopic": "静音话题", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "取消静音话题", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionUnresolveTopic": "标记为未解决", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorUnresolveTopicFailedTitle": "未能将话题标记为未解决", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "errorResolveTopicFailedTitle": "未能将话题标记为解决", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "actionSheetOptionCopyMessageText": "复制消息文本", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionMarkAsUnread": "从这里标为未读", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "actionSheetOptionShare": "分享", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "errorLoginInvalidInputTitle": "输入的信息不正确", + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "errorMessageNotSent": "未能发送消息", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "errorCouldNotConnectTitle": "未能连接", + "@errorCouldNotConnectTitle": { + "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." + }, + "discardDraftConfirmationDialogTitle": "放弃您正在撰写的消息?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "清空", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxGroupDmContentHint": "私信群组", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxSelfDmContentHint": "向自己撰写消息", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxUploadingFilename": "正在上传 {filename}…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "composeBoxTopicHintText": "话题", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "composeBoxEnterTopicOrSkipHintText": "输入话题(默认为“{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "messageListGroupYouAndOthers": "您和{others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "dialogCancel": "取消", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "loginUsernameLabel": "用户名", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "errorRequestFailed": "网络请求失败;HTTP 状态码 {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "errorVideoPlayerFailed": "未能播放视频。", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "combinedFeedPageTitle": "综合消息", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "channelsPageTitle": "频道", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "pinnedSubscriptionsLabel": "置顶", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "twoPeopleTyping": "{typist}和{otherTypist}正在输入…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + }, + "otherTypist": { + "type": "String", + "example": "Bob" + } + } + }, + "manyPeopleTyping": "多个用户正在输入…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "errorNotificationOpenTitle": "未能打开消息提醒", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "errorReactionAddingFailedTitle": "未能添加表情符号", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "未能移除表情符号", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "actionSheetOptionHideMutedMessage": "再次隐藏静音消息", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "actionSheetOptionQuoteAndReply": "引用消息并回复", + "@actionSheetOptionQuoteAndReply": { + "description": "Label for Quote and reply button on action sheet." + }, + "actionSheetOptionStarMessage": "添加星标消息标记", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionUnstarMessage": "取消星标消息标记", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "actionSheetOptionEditMessage": "编辑消息", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "将话题标为已读", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "errorWebAuthOperationalErrorTitle": "出现了一些问题", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorAccountLoggedInTitle": "已经登入该账号", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "errorWebAuthOperationalError": "发生了未知的错误。", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorCouldNotFetchMessageSource": "未能获取原始消息。", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "errorCopyingFailed": "未能复制消息文本", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "errorFailedToUploadFileTitle": "未能上传文件:{filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "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" + } + } + }, + "errorFilesTooLargeTitle": "文件过大", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorLoginFailedTitle": "未能登入", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorMessageEditNotSaved": "未能保存消息编辑", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorLoginCouldNotConnect": "未能连接到服务器:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorQuotationFailed": "未能引用消息", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "errorConnectingToServerShort": "未能连接到 Zulip. 重试中…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorFilesTooLarge": "{num, plural, other{{num} 个您上传的文件}}大小超过了该组织 {maxFileUploadSizeMib} MiB 的限制:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "2" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB" + } + } + }, + "errorHandlingEventTitle": "处理 Zulip 事件时发生了一些问题。即将重连…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "errorCouldNotOpenLinkTitle": "未能打开链接", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorCouldNotOpenLink": "未能打开此链接:{url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "errorFollowTopicFailed": "未能关注话题", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorMuteTopicFailed": "未能静音话题", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorUnmuteTopicFailed": "未能取消静音话题", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorUnfollowTopicFailed": "未能取消关注话题", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorSharingFailed": "分享失败", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "errorStarMessageFailedTitle": "未能添加星标消息标记", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnstarMessageFailedTitle": "未能取消星标消息标记", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "errorCouldNotEditMessageTitle": "未能编辑消息", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "successLinkCopied": "已复制链接", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "successMessageLinkCopied": "已复制消息链接", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "errorBannerCannotPostInChannelLabel": "您没有足够的权限在此频道发送消息。", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "composeBoxBannerLabelEditMessage": "编辑消息", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonCancel": "取消", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonSave": "保存", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressMessage": "已有正在被编辑的消息。请在其完成后重试。", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "保存中…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "编辑失败", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftForEditConfirmationDialogMessage": "当您编辑消息时,文本框中已有的内容将会被清空。", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "composeBoxAttachFilesTooltip": "上传文件", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "composeBoxAttachMediaTooltip": "上传图片或视频", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "composeBoxAttachFromCameraTooltip": "拍摄照片", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxGenericContentHint": "撰写消息", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "newDmSheetSearchHintEmpty": "添加一个或多个用户", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetScreenTitle": "发起私信", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "发起私信", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "newDmSheetSearchHintSomeSelected": "添加更多用户…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "newDmSheetNoUsersFound": "没有用户", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "composeBoxDmContentHint": "私信 @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "dmsWithYourselfPageTitle": "与自己的私信", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "contentValidationErrorUploadInProgress": "请等待上传完成。", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "contentValidationErrorEmpty": "发送的消息不能为空!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "contentValidationErrorQuoteAndReplyInProgress": "请等待引用消息完成。", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "dialogContinue": "继续", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "dialogClose": "关闭", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "snackBarDetails": "详情", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "loginErrorMissingEmail": "请输入电子邮箱地址。", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "loginPasswordLabel": "密码", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginErrorMissingPassword": "请输入密码。", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "serverUrlValidationErrorEmpty": "请输入网址。", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "serverUrlValidationErrorUnsupportedScheme": "服务器网址必须以 http:// 或 https:// 开头。", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "errorMarkAsReadFailedTitle": "未能将消息标为已读", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "markAsUnreadComplete": "已将 {num, plural, other{{num} 条消息}}标为未读。", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "today": "今天", + "@today": { + "description": "Term to use to reference the current day." + }, + "yesterday": "昨天", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "userRoleOwner": "所有者", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleGuest": "访客", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "recentDmConversationsSectionHeader": "私信", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "recentDmConversationsPageTitle": "私信", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "mentionsPageTitle": "@提及", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "starredMessagesPageTitle": "星标消息", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "mainMenuMyProfile": "个人资料", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "topicsButtonLabel": "话题", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "notifSelfUser": "您", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "reactedEmojiSelfUser": "您", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "wildcardMentionAll": "所有人", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionAllDmDescription": "通知收件人", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "messageIsMovedLabel": "已移动", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingTitle": "主题", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "emojiReactionsMore": "更多", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "errorNotificationOpenAccountNotFound": "未能找到关联该消息提醒的账号。", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "emojiPickerSearchEmoji": "搜索表情符号", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "scrollToBottomTooltip": "拖动到最底", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "revealButtonLabel": "显示静音用户发送的消息", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedSender": "静音发送者", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "mutedUser": "静音用户", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "wildcardMentionStreamDescription": "通知频道", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "pollWidgetQuestionMissing": "无问题。", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + } } diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb index 201cee2e56..de5f3b4cac 100644 --- a/assets/l10n/app_zh_Hant_TW.arb +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -1,3 +1,162 @@ { - "settingsPageTitle": "設定" + "settingsPageTitle": "設定", + "@settingsPageTitle": {}, + "aboutPageTitle": "關於 Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "chooseAccountPageLogOutButton": "登出", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "tryAnotherAccountMessage": "你在 {url} 的帳號載入的比較久", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "chooseAccountPageTitle": "選取帳號", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "aboutPageAppVersion": "App 版本", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "switchAccountButton": "切換帳號", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "actionSheetOptionListOfTopics": "主題列表", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionMuteTopic": "將主題設為靜音", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionResolveTopic": "標註為解決了", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "tryAnotherAccountButton": "請嘗試別的帳號", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "aboutPageTapToView": "點選查看", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "aboutPageOpenSourceLicenses": "開源軟體授權條款", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "logOutConfirmationDialogTitle": "登出?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "登出", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "profileButtonSendDirectMessage": "發送私訊", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "chooseAccountButtonAddAnAccount": "新增帳號", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "permissionsNeededTitle": "需要的權限", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "permissionsNeededOpenSettings": "開啟設定", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "actionSheetOptionMarkChannelAsRead": "標註頻道已讀", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionUnmuteTopic": "將主題取消靜音", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionUnresolveTopic": "標註為未解決", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "無法標註為解決了", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "無法標註為未解決", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "actionSheetOptionCopyMessageText": "複製訊息文字", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionCopyMessageLink": "複製訊息連結", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "actionSheetOptionMarkAsUnread": "從這裡開始註記為未讀", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "標註主題為已讀", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "actionSheetOptionShare": "分享", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionQuoteAndReply": "引用並回覆", + "@actionSheetOptionQuoteAndReply": { + "description": "Label for Quote and reply button on action sheet." + }, + "actionSheetOptionStarMessage": "標註為重要訊息", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionUnstarMessage": "取消標註為重要訊息", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "actionSheetOptionEditMessage": "編輯訊息", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorWebAuthOperationalErrorTitle": "出錯了", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorWebAuthOperationalError": "出現了意外的錯誤。", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorAccountLoggedInTitle": "帳號已經登入了", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "errorAccountLoggedIn": "在 {server} 的帳號 {email} 已經存在帳號清單中。", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 68d47c3787..fe3bac3607 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -8,11 +8,13 @@ import 'package:intl/intl.dart' as intl; import 'zulip_localizations_ar.dart'; import 'zulip_localizations_de.dart'; import 'zulip_localizations_en.dart'; +import 'zulip_localizations_it.dart'; import 'zulip_localizations_ja.dart'; import 'zulip_localizations_nb.dart'; import 'zulip_localizations_pl.dart'; import 'zulip_localizations_ru.dart'; import 'zulip_localizations_sk.dart'; +import 'zulip_localizations_sl.dart'; import 'zulip_localizations_uk.dart'; import 'zulip_localizations_zh.dart'; @@ -106,11 +108,13 @@ abstract class ZulipLocalizations { Locale('ar'), Locale('de'), Locale('en', 'GB'), + Locale('it'), Locale('ja'), Locale('nb'), Locale('pl'), Locale('ru'), Locale('sk'), + Locale('sl'), Locale('uk'), Locale('zh'), Locale.fromSubtags( @@ -1552,11 +1556,13 @@ class _ZulipLocalizationsDelegate 'ar', 'de', 'en', + 'it', 'ja', 'nb', 'pl', 'ru', 'sk', + 'sl', 'uk', 'zh', ].contains(locale.languageCode); @@ -1594,6 +1600,8 @@ ZulipLocalizations lookupZulipLocalizations(Locale locale) { return ZulipLocalizationsDe(); case 'en': return ZulipLocalizationsEn(); + case 'it': + return ZulipLocalizationsIt(); case 'ja': return ZulipLocalizationsJa(); case 'nb': @@ -1604,6 +1612,8 @@ ZulipLocalizations lookupZulipLocalizations(Locale locale) { return ZulipLocalizationsRu(); case 'sk': return ZulipLocalizationsSk(); + case 'sl': + return ZulipLocalizationsSl(); case 'uk': return ZulipLocalizationsUk(); case 'zh': diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index a01b813f0f..832b1f05fc 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -15,7 +15,7 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get aboutPageAppVersion => 'App-Version'; @override - String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + String get aboutPageOpenSourceLicenses => 'Open-Source-Lizenzen'; @override String get aboutPageTapToView => 'Tap to view'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart new file mode 100644 index 0000000000..9dd440121e --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -0,0 +1,839 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class ZulipLocalizationsIt extends ZulipLocalizations { + ZulipLocalizationsIt([String locale = 'it']) : super(locale); + + @override + String get aboutPageTitle => 'About Zulip'; + + @override + String get aboutPageAppVersion => 'App version'; + + @override + String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + + @override + String get aboutPageTapToView => 'Tap to view'; + + @override + String get chooseAccountPageTitle => 'Choose account'; + + @override + String get settingsPageTitle => 'Settings'; + + @override + String get switchAccountButton => 'Switch account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Your account at $url is taking a while to load.'; + } + + @override + String get tryAnotherAccountButton => 'Try another account'; + + @override + String get chooseAccountPageLogOutButton => 'Log out'; + + @override + String get logOutConfirmationDialogTitle => 'Log out?'; + + @override + String get logOutConfirmationDialogMessage => + 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Log out'; + + @override + String get chooseAccountButtonAddAnAccount => 'Add an account'; + + @override + String get profileButtonSendDirectMessage => 'Send direct message'; + + @override + String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + + @override + String get permissionsNeededTitle => 'Permissions needed'; + + @override + String get permissionsNeededOpenSettings => 'Open settings'; + + @override + String get permissionsDeniedCameraAccess => + 'To upload an image, please grant Zulip additional permissions in Settings.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'To upload files, please grant Zulip additional permissions in Settings.'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + + @override + String get actionSheetOptionListOfTopics => 'List of topics'; + + @override + String get actionSheetOptionMuteTopic => 'Mute topic'; + + @override + String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + + @override + String get actionSheetOptionFollowTopic => 'Follow topic'; + + @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'; + + @override + String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + + @override + String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + + @override + String get actionSheetOptionShare => 'Share'; + + @override + String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + + @override + String get actionSheetOptionStarMessage => 'Star message'; + + @override + String get actionSheetOptionUnstarMessage => 'Unstar message'; + + @override + String get actionSheetOptionEditMessage => 'Edit message'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + + @override + String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + + @override + String get errorAccountLoggedInTitle => 'Account already logged in'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'The account $email at $server is already in your list of accounts.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Could not fetch message source.'; + + @override + String get errorCopyingFailed => 'Copying failed'; + + @override + String errorFailedToUploadFileTitle(String filename) { + 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( + num, + locale: localeName, + other: '$num files are', + one: 'File is', + ); + return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Files', + one: 'File', + ); + return '$_temp0 too large'; + } + + @override + String get errorLoginInvalidInputTitle => 'Invalid input'; + + @override + String get errorLoginFailedTitle => 'Login failed'; + + @override + String get errorMessageNotSent => 'Message not sent'; + + @override + String get errorMessageEditNotSaved => 'Message not saved'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Failed to connect to server:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Could not connect'; + + @override + String get errorMessageDoesNotSeemToExist => + 'That message does not seem to exist.'; + + @override + String get errorQuotationFailed => 'Quotation failed'; + + @override + String errorServerMessage(String message) { + return 'The server said:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Error connecting to Zulip. Retrying…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Error handling a Zulip event. Retrying connection…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Link could not be opened: $url'; + } + + @override + String get errorMuteTopicFailed => 'Failed to mute topic'; + + @override + String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + + @override + String get errorFollowTopicFailed => 'Failed to follow topic'; + + @override + String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + + @override + String get errorSharingFailed => 'Sharing failed'; + + @override + String get errorStarMessageFailedTitle => 'Failed to star message'; + + @override + String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + + @override + String get successLinkCopied => 'Link copied'; + + @override + String get successMessageTextCopied => 'Message text copied'; + + @override + String get successMessageLinkCopied => 'Message link copied'; + + @override + String get errorBannerDeactivatedDmLabel => + 'You cannot send messages to deactivated users.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'You do not have permission to post in this channel.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get editAlreadyInProgressTitle => 'Cannot edit message'; + + @override + String get editAlreadyInProgressMessage => + 'An edit is already in progress. Please wait for it to complete.'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + + @override + String get composeBoxAttachFilesTooltip => 'Attach files'; + + @override + String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + + @override + String get composeBoxGenericContentHint => 'Type a message'; + + @override + String get newDmSheetComposeButtonLabel => 'Compose'; + + @override + String get newDmSheetScreenTitle => 'New DM'; + + @override + String get newDmFabButtonLabel => 'New DM'; + + @override + String get newDmSheetSearchHintEmpty => 'Add one or more users'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + + @override + String get newDmSheetNoUsersFound => 'No users found'; + + @override + String composeBoxDmContentHint(String user) { + return 'Message @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Message group'; + + @override + String get composeBoxSelfDmContentHint => 'Jot down something'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Message $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Preparing…'; + + @override + String get composeBoxSendTooltip => 'Send'; + + @override + String get unknownChannelName => '(unknown channel)'; + + @override + String get composeBoxTopicHintText => 'Topic'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Uploading $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(loading message $messageId)'; + } + + @override + String get unknownUserName => '(unknown user)'; + + @override + String get dmsWithYourselfPageTitle => 'DMs with yourself'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'You and $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'DMs with $others'; + } + + @override + String get messageListGroupYouWithYourself => 'Messages with yourself'; + + @override + String get contentValidationErrorTooLong => + 'Message length shouldn\'t be greater than 10000 characters.'; + + @override + String get contentValidationErrorEmpty => 'You have nothing to send!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Please wait for the quotation to complete.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Please wait for the upload to complete.'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogContinue => 'Continue'; + + @override + String get dialogClose => 'Close'; + + @override + String get errorDialogLearnMore => 'Learn more'; + + @override + String get errorDialogContinue => 'OK'; + + @override + String get errorDialogTitle => 'Error'; + + @override + String get snackBarDetails => 'Details'; + + @override + String get lightboxCopyLinkTooltip => 'Copy link'; + + @override + String get lightboxVideoCurrentPosition => 'Current position'; + + @override + String get lightboxVideoDuration => 'Video duration'; + + @override + String get loginPageTitle => 'Log in'; + + @override + String get loginFormSubmitLabel => 'Log in'; + + @override + String get loginMethodDivider => 'OR'; + + @override + String signInWithFoo(String method) { + return 'Sign in with $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Add an account'; + + @override + String get loginServerUrlLabel => 'Your Zulip server URL'; + + @override + String get loginHidePassword => 'Hide password'; + + @override + String get loginEmailLabel => 'Email address'; + + @override + String get loginErrorMissingEmail => 'Please enter your email.'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginErrorMissingPassword => 'Please enter your password.'; + + @override + String get loginUsernameLabel => 'Username'; + + @override + String get loginErrorMissingUsername => 'Please enter your username.'; + + @override + String get topicValidationErrorTooLong => + 'Topic length shouldn\'t be greater than 60 characters.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Topics are required in this organization.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + + @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.'; + + @override + String get errorNetworkRequestFailed => 'Network request failed'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Server gave malformed response; HTTP status $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Server gave malformed response; HTTP status $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Network request failed: HTTP status $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Unable to play the video.'; + + @override + String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Please enter the server URL, not your email.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'The server URL must start with http:// or https://.'; + + @override + String get spoilerDefaultHeaderText => 'Spoiler'; + + @override + String get markAllAsReadLabel => 'Mark all messages as read'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as read.'; + } + + @override + String get markAsReadInProgress => 'Marking messages as read…'; + + @override + String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num messages', + one: '1 message', + ); + return 'Marked $_temp0 as unread.'; + } + + @override + String get markAsUnreadInProgress => 'Marking messages as unread…'; + + @override + String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + + @override + String get today => 'Today'; + + @override + String get yesterday => 'Yesterday'; + + @override + String get userRoleOwner => 'Owner'; + + @override + String get userRoleAdministrator => 'Administrator'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Member'; + + @override + String get userRoleGuest => 'Guest'; + + @override + String get userRoleUnknown => 'Unknown'; + + @override + String get inboxPageTitle => 'Inbox'; + + @override + String get inboxEmptyPlaceholder => + 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + + @override + String get recentDmConversationsPageTitle => 'Direct messages'; + + @override + String get recentDmConversationsSectionHeader => 'Direct messages'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'You have no direct messages yet! Why not start the conversation?'; + + @override + String get combinedFeedPageTitle => 'Combined feed'; + + @override + String get mentionsPageTitle => 'Mentions'; + + @override + String get starredMessagesPageTitle => 'Starred messages'; + + @override + String get channelsPageTitle => 'Channels'; + + @override + String get channelsEmptyPlaceholder => + 'You are not subscribed to any channels yet.'; + + @override + String get mainMenuMyProfile => 'My profile'; + + @override + String get topicsButtonLabel => 'TOPICS'; + + @override + String get channelFeedButtonTooltip => 'Channel feed'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers others', + one: '1 other', + ); + return '$senderFullName to you and $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pinned'; + + @override + String get unpinnedSubscriptionsLabel => 'Unpinned'; + + @override + String get notifSelfUser => 'You'; + + @override + String get reactedEmojiSelfUser => 'You'; + + @override + String onePersonTyping(String typist) { + return '$typist is typing…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist and $otherTypist are typing…'; + } + + @override + String get manyPeopleTyping => 'Several people are typing…'; + + @override + String get wildcardMentionAll => 'all'; + + @override + String get wildcardMentionEveryone => 'everyone'; + + @override + String get wildcardMentionChannel => 'channel'; + + @override + String get wildcardMentionStream => 'stream'; + + @override + String get wildcardMentionTopic => 'topic'; + + @override + String get wildcardMentionChannelDescription => 'Notify channel'; + + @override + String get wildcardMentionStreamDescription => 'Notify stream'; + + @override + String get wildcardMentionAllDmDescription => 'Notify recipients'; + + @override + String get wildcardMentionTopicDescription => 'Notify topic'; + + @override + String get messageIsEditedLabel => 'EDITED'; + + @override + String get messageIsMovedLabel => 'MOVED'; + + @override + String get messageNotSentLabel => 'MESSAGE NOT SENT'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'THEME'; + + @override + String get themeSettingDark => 'Dark'; + + @override + String get themeSettingLight => 'Light'; + + @override + String get themeSettingSystem => 'System'; + + @override + String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + + @override + String get pollWidgetQuestionMissing => 'No question.'; + + @override + String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + + @override + String get experimentalFeatureSettingsWarning => + 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + + @override + String get errorNotificationOpenTitle => 'Failed to open notification'; + + @override + String get errorNotificationOpenAccountNotFound => + 'The account associated with this notification could not be found.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get noEarlierMessages => 'No earlier messages'; + + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + + @override + String get scrollToBottomTooltip => 'Scroll to bottom'; + + @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 9d7b09ded9..9e3777df75 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -353,7 +353,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Ввести сообщение'; @override - String get newDmSheetComposeButtonLabel => 'Compose'; + String get newDmSheetComposeButtonLabel => 'Написать'; @override String get newDmSheetScreenTitle => 'Новое ЛС'; @@ -652,7 +652,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get inboxEmptyPlaceholder => - 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + 'Нет непрочитанных входящих сообщений. Используйте кнопки ниже для просмотра объединенной ленты или списка каналов.'; @override String get recentDmConversationsPageTitle => 'Личные сообщения'; @@ -662,7 +662,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get recentDmConversationsEmptyPlaceholder => - 'You have no direct messages yet! Why not start the conversation?'; + 'У вас пока нет личных сообщений! Почему бы не начать беседу?'; @override String get combinedFeedPageTitle => 'Объединенная лента'; @@ -678,7 +678,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get channelsEmptyPlaceholder => - 'You are not subscribed to any channels yet.'; + 'Вы еще не подписаны ни на один канал.'; @override String get mainMenuMyProfile => 'Мой профиль'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart new file mode 100644 index 0000000000..0309756c05 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -0,0 +1,862 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Slovenian (`sl`). +class ZulipLocalizationsSl extends ZulipLocalizations { + ZulipLocalizationsSl([String locale = 'sl']) : super(locale); + + @override + String get aboutPageTitle => 'O Zulipu'; + + @override + String get aboutPageAppVersion => 'Različica aplikacije'; + + @override + String get aboutPageOpenSourceLicenses => 'Odprtokodne licence'; + + @override + String get aboutPageTapToView => 'Dotaknite se za ogled'; + + @override + String get chooseAccountPageTitle => 'Izberite račun'; + + @override + String get settingsPageTitle => 'Nastavitve'; + + @override + String get switchAccountButton => 'Preklopi račun'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Nalaganje vašega računa na $url traja dlje kot običajno.'; + } + + @override + String get tryAnotherAccountButton => 'Poskusite z drugim računom'; + + @override + String get chooseAccountPageLogOutButton => 'Odjava'; + + @override + String get logOutConfirmationDialogTitle => 'Se želite odjaviti?'; + + @override + String get logOutConfirmationDialogMessage => + 'Če boste ta račun želeli uporabljati v prihodnje, boste morali znova vnesti URL svoje organizacije in podatke za prijavo.'; + + @override + String get logOutConfirmationDialogConfirmButton => 'Odjavi se'; + + @override + String get chooseAccountButtonAddAnAccount => 'Dodaj račun'; + + @override + String get profileButtonSendDirectMessage => 'Pošlji neposredno sporočilo'; + + @override + String get errorCouldNotShowUserProfile => + 'Uporabniškega profila ni mogoče prikazati.'; + + @override + String get permissionsNeededTitle => 'Potrebna so dovoljenja'; + + @override + String get permissionsNeededOpenSettings => 'Odpri nastavitve'; + + @override + String get permissionsDeniedCameraAccess => + 'Za nalaganje slik v nastavitvah omogočite Zulipu dostop do kamere.'; + + @override + String get permissionsDeniedReadExternalStorage => + 'Za nalaganje datotek v nastavitvah omogočite Zulipu dostop do shrambe datotek.'; + + @override + String get actionSheetOptionMarkChannelAsRead => 'Označi kanal kot prebran'; + + @override + String get actionSheetOptionListOfTopics => 'Seznam tem'; + + @override + String get actionSheetOptionMuteTopic => 'Utišaj temo'; + + @override + String get actionSheetOptionUnmuteTopic => 'Prekliči utišanje teme'; + + @override + String get actionSheetOptionFollowTopic => 'Sledi temi'; + + @override + String get actionSheetOptionUnfollowTopic => 'Prenehaj slediti temi'; + + @override + String get actionSheetOptionResolveTopic => 'Označi kot razrešeno'; + + @override + String get actionSheetOptionUnresolveTopic => 'Označi kot nerazrešeno'; + + @override + String get errorResolveTopicFailedTitle => + 'Neuspela označitev teme kot razrešene'; + + @override + String get errorUnresolveTopicFailedTitle => + 'Neuspela označitev teme kot nerazrešene'; + + @override + String get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila'; + + @override + String get actionSheetOptionCopyMessageLink => + 'Kopiraj povezavo do sporočila'; + + @override + String get actionSheetOptionMarkAsUnread => + 'Od tu naprej označi kot neprebrano'; + + @override + String get actionSheetOptionHideMutedMessage => + 'Znova skrij utišano sporočilo'; + + @override + String get actionSheetOptionShare => 'Deli'; + + @override + String get actionSheetOptionQuoteAndReply => 'Citiraj in odgovori'; + + @override + String get actionSheetOptionStarMessage => 'Označi sporočilo z zvezdico'; + + @override + String get actionSheetOptionUnstarMessage => 'Odstrani zvezdico s sporočila'; + + @override + String get actionSheetOptionEditMessage => 'Uredi sporočilo'; + + @override + String get actionSheetOptionMarkTopicAsRead => 'Označi temo kot prebrano'; + + @override + String get errorWebAuthOperationalErrorTitle => 'Nekaj je šlo narobe'; + + @override + String get errorWebAuthOperationalError => + 'Prišlo je do nepričakovane napake.'; + + @override + String get errorAccountLoggedInTitle => 'Račun je že prijavljen'; + + @override + String errorAccountLoggedIn(String email, String server) { + return 'Račun $email na $server je že na vašem seznamu računov.'; + } + + @override + String get errorCouldNotFetchMessageSource => + 'Ni bilo mogoče pridobiti vira sporočila.'; + + @override + String get errorCopyingFailed => 'Kopiranje ni uspelo'; + + @override + String errorFailedToUploadFileTitle(String filename) { + return 'Nalaganje datoteke ni uspelo: $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( + num, + locale: localeName, + other: '$num datotek presega', + few: '$num datoteke presegajo', + one: 'Dve datoteki presegata', + ); + String _temp1 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'ne bodo naložene', + few: 'ne bodo naložene', + one: 'ne bosta naloženi', + ); + return '$_temp0 omejitev velikosti strežnika ($maxFileUploadSizeMib MiB) in $_temp1:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num datotek je prevelikih', + few: '$num datoteke so prevelike', + one: 'Dve datoteki sta preveliki', + ); + return '\"$_temp0\"'; + } + + @override + String get errorLoginInvalidInputTitle => 'Neveljaven vnos'; + + @override + String get errorLoginFailedTitle => 'Prijava ni uspela'; + + @override + String get errorMessageNotSent => 'Pošiljanje sporočila ni uspelo'; + + @override + String get errorMessageEditNotSaved => 'Sporočilo ni bilo shranjeno'; + + @override + String errorLoginCouldNotConnect(String url) { + return 'Ni se mogoče povezati s strežnikom:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => 'Povezave ni bilo mogoče vzpostaviti'; + + @override + String get errorMessageDoesNotSeemToExist => + 'Zdi se, da to sporočilo ne obstaja.'; + + @override + String get errorQuotationFailed => 'Citiranje ni uspelo'; + + @override + String errorServerMessage(String message) { + return 'Strežnik je sporočil:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => + 'Napaka pri povezovanju z Zulipom. Poskušamo znova…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return 'Napaka pri povezovanju z Zulipom na $serverUrl. Poskusili bomo znova:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => + 'Napaka pri obravnavi posodobitve. Povezujemo se znova…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return 'Napaka pri obravnavi posodobitve iz strežnika $serverUrl; poskusili bomo znova.\n\nNapaka: $error\n\nDogodek: $event'; + } + + @override + String get errorCouldNotOpenLinkTitle => 'Povezave ni mogoče odpreti'; + + @override + String errorCouldNotOpenLink(String url) { + return 'Povezave ni bilo mogoče odpreti: $url'; + } + + @override + String get errorMuteTopicFailed => 'Utišanje teme ni uspelo'; + + @override + String get errorUnmuteTopicFailed => 'Preklic utišanja teme ni uspel'; + + @override + String get errorFollowTopicFailed => 'Sledenje temi ni uspelo'; + + @override + String get errorUnfollowTopicFailed => 'Prenehanje sledenja temi ni uspelo'; + + @override + String get errorSharingFailed => 'Deljenje ni uspelo'; + + @override + String get errorStarMessageFailedTitle => + 'Sporočila ni bilo mogoče označiti z zvezdico'; + + @override + String get errorUnstarMessageFailedTitle => + 'Sporočilu ni bilo mogoče odstraniti zvezdice'; + + @override + String get errorCouldNotEditMessageTitle => 'Sporočila ni mogoče urediti'; + + @override + String get successLinkCopied => 'Povezava je bila kopirana'; + + @override + String get successMessageTextCopied => 'Besedilo sporočila je bilo kopirano'; + + @override + String get successMessageLinkCopied => + 'Povezava do sporočila je bila kopirana'; + + @override + String get errorBannerDeactivatedDmLabel => + 'Deaktiviranim uporabnikom ne morete pošiljati sporočil.'; + + @override + String get errorBannerCannotPostInChannelLabel => + 'Nimate dovoljenja za objavljanje v tem kanalu.'; + + @override + String get composeBoxBannerLabelEditMessage => 'Uredi sporočilo'; + + @override + String get composeBoxBannerButtonCancel => 'Prekliči'; + + @override + String get composeBoxBannerButtonSave => 'Shrani'; + + @override + String get editAlreadyInProgressTitle => 'Urejanje sporočila ni mogoče'; + + @override + String get editAlreadyInProgressMessage => + 'Urejanje je že v teku. Počakajte, da se konča.'; + + @override + String get savingMessageEditLabel => 'SHRANJEVANJE SPREMEMB…'; + + @override + String get savingMessageEditFailedLabel => 'UREJANJE NI SHRANJENO'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Želite zavreči sporočilo, ki ga pišete?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + 'Ko urejate sporočilo, se prejšnja vsebina polja za pisanje zavrže.'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Zavrzi'; + + @override + String get composeBoxAttachFilesTooltip => 'Pripni datoteke'; + + @override + String get composeBoxAttachMediaTooltip => + 'Pripni fotografije ali videoposnetke'; + + @override + String get composeBoxAttachFromCameraTooltip => 'Fotografiraj'; + + @override + String get composeBoxGenericContentHint => 'Vnesite sporočilo'; + + @override + String get newDmSheetComposeButtonLabel => 'Napiši'; + + @override + String get newDmSheetScreenTitle => 'Novo neposredno sporočilo'; + + @override + String get newDmFabButtonLabel => 'Novo neposredno sporočilo'; + + @override + String get newDmSheetSearchHintEmpty => 'Dodajte enega ali več uporabnikov'; + + @override + String get newDmSheetSearchHintSomeSelected => 'Dodajte še enega uporabnika…'; + + @override + String get newDmSheetNoUsersFound => 'Ni zadetkov med uporabniki'; + + @override + String composeBoxDmContentHint(String user) { + return 'Sporočilo @$user'; + } + + @override + String get composeBoxGroupDmContentHint => 'Skupinsko sporočilo'; + + @override + String get composeBoxSelfDmContentHint => 'Zapišite opombo zase'; + + @override + String composeBoxChannelContentHint(String destination) { + return 'Sporočilo $destination'; + } + + @override + String get preparingEditMessageContentInput => 'Pripravljanje…'; + + @override + String get composeBoxSendTooltip => 'Pošlji'; + + @override + String get unknownChannelName => '(neznan kanal)'; + + @override + String get composeBoxTopicHintText => 'Tema'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Vnesite temo (ali pustite prazno za »$defaultTopicName«)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return 'Nalaganje $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(nalaganje sporočila $messageId)'; + } + + @override + String get unknownUserName => '(neznan uporabnik)'; + + @override + String get dmsWithYourselfPageTitle => 'Neposredna sporočila s samim seboj'; + + @override + String messageListGroupYouAndOthers(String others) { + return 'Vi in $others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return 'Neposredna sporočila z $others'; + } + + @override + String get messageListGroupYouWithYourself => 'Sporočila sebi'; + + @override + String get contentValidationErrorTooLong => + 'Dolžina sporočila ne sme presegati 10000 znakov.'; + + @override + String get contentValidationErrorEmpty => 'Ni vsebine za pošiljanje!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => + 'Počakajte, da se citat zaključi.'; + + @override + String get contentValidationErrorUploadInProgress => + 'Počakajte, da se nalaganje konča.'; + + @override + String get dialogCancel => 'Prekliči'; + + @override + String get dialogContinue => 'Nadaljuj'; + + @override + String get dialogClose => 'Zapri'; + + @override + String get errorDialogLearnMore => 'Več o tem'; + + @override + String get errorDialogContinue => 'V redu'; + + @override + String get errorDialogTitle => 'Napaka'; + + @override + String get snackBarDetails => 'Podrobnosti'; + + @override + String get lightboxCopyLinkTooltip => 'Kopiraj povezavo'; + + @override + String get lightboxVideoCurrentPosition => 'Trenutni položaj'; + + @override + String get lightboxVideoDuration => 'Trajanje videa'; + + @override + String get loginPageTitle => 'Prijava'; + + @override + String get loginFormSubmitLabel => 'Prijava'; + + @override + String get loginMethodDivider => 'ALI'; + + @override + String signInWithFoo(String method) { + return 'Prijava z $method'; + } + + @override + String get loginAddAnAccountPageTitle => 'Dodaj račun'; + + @override + String get loginServerUrlLabel => 'URL strežnika Zulip'; + + @override + String get loginHidePassword => 'Skrij geslo'; + + @override + String get loginEmailLabel => 'E-poštni naslov'; + + @override + String get loginErrorMissingEmail => 'Vnesite svoj e-poštni naslov.'; + + @override + String get loginPasswordLabel => 'Geslo'; + + @override + String get loginErrorMissingPassword => 'Vnesite svoje geslo.'; + + @override + String get loginUsernameLabel => 'Uporabniško ime'; + + @override + String get loginErrorMissingUsername => 'Vnesite svoje uporabniško ime.'; + + @override + String get topicValidationErrorTooLong => + 'Dolžina teme ne sme presegati 60 znakov.'; + + @override + String get topicValidationErrorMandatoryButEmpty => + 'Teme so v tej organizaciji obvezne.'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url uporablja strežnik Zulip $zulipVersion, ki ni podprt. Najnižja podprta različica je strežnik Zulip $minSupportedZulipVersion.'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return 'Vašega računa na $url ni bilo mogoče overiti. Poskusite se znova prijaviti ali uporabite drug račun.'; + } + + @override + String get errorInvalidResponse => 'Strežnik je poslal neveljaven odgovor.'; + + @override + String get errorNetworkRequestFailed => 'Omrežna zahteva je spodletela'; + + @override + String errorMalformedResponse(int httpStatus) { + return 'Strežnik je poslal napačno oblikovan odgovor; stanje HTTP $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return 'Strežnik je poslal napačno oblikovan odgovor; stanje HTTP $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return 'Omrežna zahteva je spodletela: Stanje HTTP $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => 'Videa ni mogoče predvajati.'; + + @override + String get serverUrlValidationErrorEmpty => 'Vnesite URL.'; + + @override + String get serverUrlValidationErrorInvalidUrl => 'Vnesite veljaven URL.'; + + @override + String get serverUrlValidationErrorNoUseEmail => + 'Vnesite URL strežnika, ne vašega e-poštnega naslova.'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + 'URL strežnika se mora začeti s http:// ali https://.'; + + @override + String get spoilerDefaultHeaderText => 'Skrito'; + + @override + String get markAllAsReadLabel => 'Označi vsa sporočila kot prebrana'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num sporočil', + few: '$num sporočila', + one: '2 sporočili', + ); + return 'Označeno je $_temp0 kot prebrano.'; + } + + @override + String get markAsReadInProgress => 'Označevanje sporočil kot prebranih…'; + + @override + String get errorMarkAsReadFailedTitle => 'Označevanje kot prebrano ni uspelo'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: 'Označeno je $num sporočil kot neprebranih', + few: 'Označena so $num sporočila kot neprebrana', + one: 'Označeni sta 2 sporočili kot neprebrani', + ); + return '$_temp0.'; + } + + @override + String get markAsUnreadInProgress => 'Označevanje sporočil kot neprebranih…'; + + @override + String get errorMarkAsUnreadFailedTitle => + 'Označevanje kot neprebrano ni uspelo'; + + @override + String get today => 'Danes'; + + @override + String get yesterday => 'Včeraj'; + + @override + String get userRoleOwner => 'Lastnik'; + + @override + String get userRoleAdministrator => 'Skrbnik'; + + @override + String get userRoleModerator => 'Moderator'; + + @override + String get userRoleMember => 'Član'; + + @override + String get userRoleGuest => 'Gost'; + + @override + String get userRoleUnknown => 'Neznano'; + + @override + String get inboxPageTitle => 'Nabiralnik'; + + @override + String get inboxEmptyPlaceholder => + 'V vašem nabiralniku ni neprebranih sporočil. Uporabite spodnje gumbe za ogled združenega prikaza ali seznama kanalov.'; + + @override + String get recentDmConversationsPageTitle => 'Neposredna sporočila'; + + @override + String get recentDmConversationsSectionHeader => 'Neposredna sporočila'; + + @override + String get recentDmConversationsEmptyPlaceholder => + 'Zaenkrat še nimate neposrednih sporočil! Zakaj ne bi začeli pogovora?'; + + @override + String get combinedFeedPageTitle => 'Združen prikaz'; + + @override + String get mentionsPageTitle => 'Omembe'; + + @override + String get starredMessagesPageTitle => 'Sporočila z zvezdico'; + + @override + String get channelsPageTitle => 'Kanali'; + + @override + String get channelsEmptyPlaceholder => 'Niste še naročeni na noben kanal.'; + + @override + String get mainMenuMyProfile => 'Moj profil'; + + @override + String get topicsButtonLabel => 'TEME'; + + @override + String get channelFeedButtonTooltip => 'Sporočila kanala'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers drugim osebam', + one: '1 drugi osebi', + ); + return '$senderFullName vam in $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => 'Pripeto'; + + @override + String get unpinnedSubscriptionsLabel => 'Nepripeto'; + + @override + String get notifSelfUser => 'Vi'; + + @override + String get reactedEmojiSelfUser => 'Vi'; + + @override + String onePersonTyping(String typist) { + return '$typist tipka…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist in $otherTypist tipkata…'; + } + + @override + String get manyPeopleTyping => 'Več oseb tipka…'; + + @override + String get wildcardMentionAll => 'vsi'; + + @override + String get wildcardMentionEveryone => 'vsi'; + + @override + String get wildcardMentionChannel => 'kanal'; + + @override + String get wildcardMentionStream => 'tok'; + + @override + String get wildcardMentionTopic => 'tema'; + + @override + String get wildcardMentionChannelDescription => 'Obvesti kanal'; + + @override + String get wildcardMentionStreamDescription => 'Obvesti tok'; + + @override + String get wildcardMentionAllDmDescription => 'Obvesti prejemnike'; + + @override + String get wildcardMentionTopicDescription => 'Obvesti udeležence teme'; + + @override + String get messageIsEditedLabel => 'UREJENO'; + + @override + String get messageIsMovedLabel => 'PREMAKNJENO'; + + @override + String get messageNotSentLabel => 'SPOROČILO NI POSLANO'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => 'TEMA'; + + @override + String get themeSettingDark => 'Temna'; + + @override + String get themeSettingLight => 'Svetla'; + + @override + String get themeSettingSystem => 'Sistemska'; + + @override + String get openLinksWithInAppBrowser => + 'Odpri povezave v brskalniku znotraj aplikacije'; + + @override + String get pollWidgetQuestionMissing => 'Brez vprašanja.'; + + @override + String get pollWidgetOptionsMissing => 'Ta anketa še nima odgovorov.'; + + @override + String get initialAnchorSettingTitle => 'Open message feeds at'; + + @override + String get initialAnchorSettingDescription => + 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + + @override + String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + 'First unread message in single conversations, newest message elsewhere'; + + @override + String get initialAnchorSettingNewestAlways => 'Newest message'; + + @override + String get experimentalFeatureSettingsPageTitle => 'Eksperimentalne funkcije'; + + @override + String get experimentalFeatureSettingsWarning => + 'Te možnosti omogočajo funkcije, ki so še v razvoju in niso pripravljene. Morda ne bodo delovale in lahko povzročijo težave v drugih delih aplikacije.\n\nNamen teh nastavitev je eksperimentiranje za uporabnike, ki delajo na razvoju Zulipa.'; + + @override + String get errorNotificationOpenTitle => 'Obvestila ni bilo mogoče odpreti'; + + @override + String get errorNotificationOpenAccountNotFound => + 'Računa, povezanega s tem obvestilom, ni bilo mogoče najti.'; + + @override + String get errorReactionAddingFailedTitle => 'Reakcije ni bilo mogoče dodati'; + + @override + String get errorReactionRemovingFailedTitle => + 'Reakcije ni bilo mogoče odstraniti'; + + @override + String get emojiReactionsMore => 'več'; + + @override + String get emojiPickerSearchEmoji => 'Iskanje emojijev'; + + @override + String get noEarlierMessages => 'Ni starejših sporočil'; + + @override + String get mutedSender => 'Utišan pošiljatelj'; + + @override + String get revealButtonLabel => 'Prikaži sporočilo utišanega pošiljatelja'; + + @override + String get mutedUser => 'Uporabnik je utišan'; + + @override + String get scrollToBottomTooltip => 'Premakni se na konec'; + + @override + String get appVersionUnknownPlaceholder => '(...)'; + + @override + String get zulipAppTitle => 'Zulip'; +} diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 97b5e26af1..735276940b 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -80,7 +80,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Позначити канал як прочитаний'; @override - String get actionSheetOptionListOfTopics => 'List of topics'; + String get actionSheetOptionListOfTopics => 'Список тем'; @override String get actionSheetOptionMuteTopic => 'Заглушити тему'; @@ -119,7 +119,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Позначити як непрочитане звідси'; @override - String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + String get actionSheetOptionHideMutedMessage => + 'Сховати заглушене повідомлення'; @override String get actionSheetOptionShare => 'Поширити'; @@ -135,7 +136,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Зняти позначку зірки з повідомлення'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'Редагувати повідомлення'; @override String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану'; @@ -156,7 +157,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Не вдалося отримати джерело повідомлення'; + 'Не вдалося отримати джерело повідомлення.'; @override String get errorCopyingFailed => 'Помилка копіювання'; @@ -207,7 +208,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorMessageNotSent => 'Повідомлення не надіслано'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Повідомлення не збережено'; @override String errorLoginCouldNotConnect(String url) { @@ -283,7 +284,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Не вдалося зняти позначку зірки з повідомлення'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => + 'Не вдалося редагувати повідомлення'; @override String get successLinkCopied => 'Посилання скопійовано'; @@ -304,41 +306,41 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'Ви не маєте дозволу на публікацію в цьому каналі.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Редагування повідомлення'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Відміна'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Зберегти'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => 'Неможливо редагувати повідомлення'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Редагування уже виконується. Дочекайтеся його завершення.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'ЗБЕРЕЖЕННЯ ПРАВОК…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'ПРАВКИ НЕ ЗБЕРЕЖЕНІ'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + 'Відмовитися від написаного повідомлення?'; @override String get discardDraftForEditConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + 'При редагуванні повідомлення, текст з поля для редагування видаляється.'; @override String get discardDraftForOutboxConfirmationDialogMessage => 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get discardDraftConfirmationDialogConfirmButton => 'Скинути'; @override String get composeBoxAttachFilesTooltip => 'Прикріпити файли'; @@ -353,22 +355,22 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Ввести повідомлення'; @override - String get newDmSheetComposeButtonLabel => 'Compose'; + String get newDmSheetComposeButtonLabel => 'Написати'; @override - String get newDmSheetScreenTitle => 'New DM'; + String get newDmSheetScreenTitle => 'Нове особисте повідомлення'; @override - String get newDmFabButtonLabel => 'New DM'; + String get newDmFabButtonLabel => 'Нове особисте повідомлення'; @override - String get newDmSheetSearchHintEmpty => 'Add one or more users'; + String get newDmSheetSearchHintEmpty => 'Додати користувачів'; @override - String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + String get newDmSheetSearchHintSomeSelected => 'Додати ще…'; @override - String get newDmSheetNoUsersFound => 'No users found'; + String get newDmSheetNoUsersFound => 'Користувачі не знайдені'; @override String composeBoxDmContentHint(String user) { @@ -387,7 +389,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Підготовка…'; @override String get composeBoxSendTooltip => 'Надіслати'; @@ -400,7 +402,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Вкажіть тему (або залиште “$defaultTopicName”)'; } @override @@ -542,7 +544,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'Сервер надіслав недійсну відповідь'; + String get errorInvalidResponse => 'Сервер надіслав недійсну відповідь.'; @override String get errorNetworkRequestFailed => 'Помилка запиту мережі'; @@ -563,7 +565,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Неможливо відтворити відео'; + String get errorVideoPlayerFailed => 'Неможливо відтворити відео.'; @override String get serverUrlValidationErrorEmpty => 'Будь ласка, введіть URL.'; @@ -650,7 +652,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get inboxEmptyPlaceholder => - 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + 'Немає непрочитаних вхідних повідомлень. Використовуйте кнопки знизу для перегляду обʼєднаної стрічки або списку каналів.'; @override String get recentDmConversationsPageTitle => 'Особисті повідомлення'; @@ -660,7 +662,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get recentDmConversationsEmptyPlaceholder => - 'You have no direct messages yet! Why not start the conversation?'; + 'У вас поки що немає особистих повідомлень! Чому б не розпочати бесіду?'; @override String get combinedFeedPageTitle => 'Об\'єднана стрічка'; @@ -675,14 +677,13 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get channelsPageTitle => 'Канали'; @override - String get channelsEmptyPlaceholder => - 'You are not subscribed to any channels yet.'; + String get channelsEmptyPlaceholder => 'Ви ще не підписані на жодний канал.'; @override String get mainMenuMyProfile => 'Мій профіль'; @override - String get topicsButtonLabel => 'TOPICS'; + String get topicsButtonLabel => 'ТЕМИ'; @override String get channelFeedButtonTooltip => 'Стрічка каналу'; @@ -757,7 +758,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get messageIsMovedLabel => 'ПЕРЕМІЩЕНО'; @override - String get messageNotSentLabel => 'MESSAGE NOT SENT'; + String get messageNotSentLabel => 'ПОВІДОМЛЕННЯ НЕ ВІДПРАВЛЕНО'; @override String pollVoterNames(String voterNames) { @@ -816,7 +817,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorNotificationOpenAccountNotFound => - 'The account associated with this notification could not be found.'; + 'Обліковий запис, звʼязаний з цим сповіщенням, не знайдений.'; @override String get errorReactionAddingFailedTitle => 'Не вдалося додати реакцію'; @@ -834,13 +835,14 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get noEarlierMessages => 'Немає попередніх повідомлень'; @override - String get mutedSender => 'Muted sender'; + String get mutedSender => 'Заглушений відправник'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => + 'Показати повідомлення заглушеного відправника'; @override - String get mutedUser => 'Muted user'; + String get mutedUser => 'Заглушений користувач'; @override String get scrollToBottomTooltip => 'Прокрутити вниз'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index e72db65ad7..5190c406a4 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -842,14 +842,920 @@ class ZulipLocalizationsZh extends ZulipLocalizations { class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { ZulipLocalizationsZhHansCn() : super('zh_Hans_CN'); + @override + String get aboutPageTitle => '关于Zulip'; + + @override + String get aboutPageAppVersion => '应用程序版本'; + + @override + String get aboutPageOpenSourceLicenses => '开源许可'; + + @override + String get aboutPageTapToView => '查看更多'; + + @override + String get chooseAccountPageTitle => '选择账号'; + @override String get settingsPageTitle => '设置'; + + @override + String get switchAccountButton => '切换账号'; + + @override + String tryAnotherAccountMessage(Object url) { + return '您在 $url 的账号加载时间过长。'; + } + + @override + String get tryAnotherAccountButton => '尝试另一个账号'; + + @override + String get chooseAccountPageLogOutButton => '登出'; + + @override + String get logOutConfirmationDialogTitle => '登出?'; + + @override + String get logOutConfirmationDialogMessage => '下次登入此账号时,您将需要重新输入组织网址和账号信息。'; + + @override + String get logOutConfirmationDialogConfirmButton => '登出'; + + @override + String get chooseAccountButtonAddAnAccount => '添加一个账号'; + + @override + String get profileButtonSendDirectMessage => '发送私信'; + + @override + String get errorCouldNotShowUserProfile => '无法显示用户个人资料。'; + + @override + String get permissionsNeededTitle => '需要额外权限'; + + @override + String get permissionsNeededOpenSettings => '打开设置'; + + @override + String get permissionsDeniedCameraAccess => '上传图片前,请在设置授予 Zulip 相应的权限。'; + + @override + String get permissionsDeniedReadExternalStorage => + '上传文件前,请在设置授予 Zulip 相应的权限。'; + + @override + String get actionSheetOptionMarkChannelAsRead => '标记频道为已读'; + + @override + String get actionSheetOptionListOfTopics => '话题列表'; + + @override + String get actionSheetOptionMuteTopic => '静音话题'; + + @override + String get actionSheetOptionUnmuteTopic => '取消静音话题'; + + @override + String get actionSheetOptionFollowTopic => '关注话题'; + + @override + String get actionSheetOptionUnfollowTopic => '取消关注话题'; + + @override + String get actionSheetOptionResolveTopic => '标记为已解决'; + + @override + String get actionSheetOptionUnresolveTopic => '标记为未解决'; + + @override + String get errorResolveTopicFailedTitle => '未能将话题标记为解决'; + + @override + String get errorUnresolveTopicFailedTitle => '未能将话题标记为未解决'; + + @override + String get actionSheetOptionCopyMessageText => '复制消息文本'; + + @override + String get actionSheetOptionCopyMessageLink => '复制消息链接'; + + @override + String get actionSheetOptionMarkAsUnread => '从这里标为未读'; + + @override + String get actionSheetOptionHideMutedMessage => '再次隐藏静音消息'; + + @override + String get actionSheetOptionShare => '分享'; + + @override + String get actionSheetOptionQuoteAndReply => '引用消息并回复'; + + @override + String get actionSheetOptionStarMessage => '添加星标消息标记'; + + @override + String get actionSheetOptionUnstarMessage => '取消星标消息标记'; + + @override + String get actionSheetOptionEditMessage => '编辑消息'; + + @override + String get actionSheetOptionMarkTopicAsRead => '将话题标为已读'; + + @override + String get errorWebAuthOperationalErrorTitle => '出现了一些问题'; + + @override + String get errorWebAuthOperationalError => '发生了未知的错误。'; + + @override + String get errorAccountLoggedInTitle => '已经登入该账号'; + + @override + String errorAccountLoggedIn(String email, String server) { + return '在 $server 的账号 $email 已经在您的账号列表了。'; + } + + @override + String get errorCouldNotFetchMessageSource => '未能获取原始消息。'; + + @override + String get errorCopyingFailed => '未能复制消息文本'; + + @override + String errorFailedToUploadFileTitle(String filename) { + 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( + num, + locale: localeName, + other: '$num 个您上传的文件', + ); + return '$_temp0大小超过了该组织 $maxFileUploadSizeMib MiB 的限制:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + return '文件过大'; + } + + @override + String get errorLoginInvalidInputTitle => '输入的信息不正确'; + + @override + String get errorLoginFailedTitle => '未能登入'; + + @override + String get errorMessageNotSent => '未能发送消息'; + + @override + String get errorMessageEditNotSaved => '未能保存消息编辑'; + + @override + String errorLoginCouldNotConnect(String url) { + return '未能连接到服务器:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => '未能连接'; + + @override + String get errorMessageDoesNotSeemToExist => '找不到此消息。'; + + @override + String get errorQuotationFailed => '未能引用消息'; + + @override + String errorServerMessage(String message) { + return '服务器:\n\n$message'; + } + + @override + String get errorConnectingToServerShort => '未能连接到 Zulip. 重试中…'; + + @override + String errorConnectingToServerDetails(String serverUrl, String error) { + return '未能连接到在 $serverUrl 的 Zulip 服务器。即将重连:\n\n$error'; + } + + @override + String get errorHandlingEventTitle => '处理 Zulip 事件时发生了一些问题。即将重连…'; + + @override + String errorHandlingEventDetails( + String serverUrl, + String error, + String event, + ) { + return '处理来自 $serverUrl 的 Zulip 事件时发生了一些问题。即将重连。\n\n错误:$error\n\n事件:$event'; + } + + @override + String get errorCouldNotOpenLinkTitle => '未能打开链接'; + + @override + String errorCouldNotOpenLink(String url) { + return '未能打开此链接:$url'; + } + + @override + String get errorMuteTopicFailed => '未能静音话题'; + + @override + String get errorUnmuteTopicFailed => '未能取消静音话题'; + + @override + String get errorFollowTopicFailed => '未能关注话题'; + + @override + String get errorUnfollowTopicFailed => '未能取消关注话题'; + + @override + String get errorSharingFailed => '分享失败'; + + @override + String get errorStarMessageFailedTitle => '未能添加星标消息标记'; + + @override + String get errorUnstarMessageFailedTitle => '未能取消星标消息标记'; + + @override + String get errorCouldNotEditMessageTitle => '未能编辑消息'; + + @override + String get successLinkCopied => '已复制链接'; + + @override + String get successMessageTextCopied => '已复制消息文本'; + + @override + String get successMessageLinkCopied => '已复制消息链接'; + + @override + String get errorBannerDeactivatedDmLabel => '您不能向被停用的用户发送消息。'; + + @override + String get errorBannerCannotPostInChannelLabel => '您没有足够的权限在此频道发送消息。'; + + @override + String get composeBoxBannerLabelEditMessage => '编辑消息'; + + @override + String get composeBoxBannerButtonCancel => '取消'; + + @override + String get composeBoxBannerButtonSave => '保存'; + + @override + String get editAlreadyInProgressTitle => '未能编辑消息'; + + @override + String get editAlreadyInProgressMessage => '已有正在被编辑的消息。请在其完成后重试。'; + + @override + String get savingMessageEditLabel => '保存中…'; + + @override + String get savingMessageEditFailedLabel => '编辑失败'; + + @override + String get discardDraftConfirmationDialogTitle => '放弃您正在撰写的消息?'; + + @override + String get discardDraftForEditConfirmationDialogMessage => + '当您编辑消息时,文本框中已有的内容将会被清空。'; + + @override + String get discardDraftForOutboxConfirmationDialogMessage => + '当您恢复未能发送的消息时,文本框已有的内容将会被清空。'; + + @override + String get discardDraftConfirmationDialogConfirmButton => '清空'; + + @override + String get composeBoxAttachFilesTooltip => '上传文件'; + + @override + String get composeBoxAttachMediaTooltip => '上传图片或视频'; + + @override + String get composeBoxAttachFromCameraTooltip => '拍摄照片'; + + @override + String get composeBoxGenericContentHint => '撰写消息'; + + @override + String get newDmSheetComposeButtonLabel => '撰写消息'; + + @override + String get newDmSheetScreenTitle => '发起私信'; + + @override + String get newDmFabButtonLabel => '发起私信'; + + @override + String get newDmSheetSearchHintEmpty => '添加一个或多个用户'; + + @override + String get newDmSheetSearchHintSomeSelected => '添加更多用户…'; + + @override + String get newDmSheetNoUsersFound => '没有用户'; + + @override + String composeBoxDmContentHint(String user) { + return '私信 @$user'; + } + + @override + String get composeBoxGroupDmContentHint => '私信群组'; + + @override + String get composeBoxSelfDmContentHint => '向自己撰写消息'; + + @override + String composeBoxChannelContentHint(String destination) { + return '发送消息到 $destination'; + } + + @override + String get preparingEditMessageContentInput => '准备编辑消息…'; + + @override + String get composeBoxSendTooltip => '发送'; + + @override + String get unknownChannelName => '(未知频道)'; + + @override + String get composeBoxTopicHintText => '话题'; + + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return '输入话题(默认为“$defaultTopicName”)'; + } + + @override + String composeBoxUploadingFilename(String filename) { + return '正在上传 $filename…'; + } + + @override + String composeBoxLoadingMessage(int messageId) { + return '(加载消息 $messageId)'; + } + + @override + String get unknownUserName => '(未知用户)'; + + @override + String get dmsWithYourselfPageTitle => '与自己的私信'; + + @override + String messageListGroupYouAndOthers(String others) { + return '您和$others'; + } + + @override + String dmsWithOthersPageTitle(String others) { + return '与$others的私信'; + } + + @override + String get messageListGroupYouWithYourself => '与自己的私信'; + + @override + String get contentValidationErrorTooLong => '消息的长度不能超过10000个字符。'; + + @override + String get contentValidationErrorEmpty => '发送的消息不能为空!'; + + @override + String get contentValidationErrorQuoteAndReplyInProgress => '请等待引用消息完成。'; + + @override + String get contentValidationErrorUploadInProgress => '请等待上传完成。'; + + @override + String get dialogCancel => '取消'; + + @override + String get dialogContinue => '继续'; + + @override + String get dialogClose => '关闭'; + + @override + String get errorDialogLearnMore => '更多信息'; + + @override + String get errorDialogContinue => '好的'; + + @override + String get errorDialogTitle => '错误'; + + @override + String get snackBarDetails => '详情'; + + @override + String get lightboxCopyLinkTooltip => '复制链接'; + + @override + String get lightboxVideoCurrentPosition => '当前进度'; + + @override + String get lightboxVideoDuration => '视频时长'; + + @override + String get loginPageTitle => '登入'; + + @override + String get loginFormSubmitLabel => '登入'; + + @override + String get loginMethodDivider => '或'; + + @override + String signInWithFoo(String method) { + return '使用$method登入'; + } + + @override + String get loginAddAnAccountPageTitle => '添加账号'; + + @override + String get loginServerUrlLabel => 'Zulip 服务器网址'; + + @override + String get loginHidePassword => '隐藏密码'; + + @override + String get loginEmailLabel => '电子邮箱地址'; + + @override + String get loginErrorMissingEmail => '请输入电子邮箱地址。'; + + @override + String get loginPasswordLabel => '密码'; + + @override + String get loginErrorMissingPassword => '请输入密码。'; + + @override + String get loginUsernameLabel => '用户名'; + + @override + String get loginErrorMissingUsername => '请输入用户名。'; + + @override + String get topicValidationErrorTooLong => '话题长度不应该超过 60 个字符。'; + + @override + String get topicValidationErrorMandatoryButEmpty => '话题在该组织为必填项。'; + + @override + String errorServerVersionUnsupportedMessage( + String url, + String zulipVersion, + String minSupportedZulipVersion, + ) { + return '$url 运行的 Zulip 服务器版本 $zulipVersion 过低。该客户端只支持 $minSupportedZulipVersion 及以后的服务器版本。'; + } + + @override + String errorInvalidApiKeyMessage(String url) { + return '您在 $url 的账号无法被登入。请重试或者使用另外的账号。'; + } + + @override + String get errorInvalidResponse => '服务器的回复不合法。'; + + @override + String get errorNetworkRequestFailed => '网络请求失败'; + + @override + String errorMalformedResponse(int httpStatus) { + return '服务器的回复不合法;HTTP 状态码 $httpStatus'; + } + + @override + String errorMalformedResponseWithCause(int httpStatus, String details) { + return '服务器的回复不合法;HTTP 状态码 $httpStatus; $details'; + } + + @override + String errorRequestFailed(int httpStatus) { + return '网络请求失败;HTTP 状态码 $httpStatus'; + } + + @override + String get errorVideoPlayerFailed => '未能播放视频。'; + + @override + String get serverUrlValidationErrorEmpty => '请输入网址。'; + + @override + String get serverUrlValidationErrorInvalidUrl => '请输入正确的网址。'; + + @override + String get serverUrlValidationErrorNoUseEmail => '请输入服务器网址,而不是您的电子邮件。'; + + @override + String get serverUrlValidationErrorUnsupportedScheme => + '服务器网址必须以 http:// 或 https:// 开头。'; + + @override + String get spoilerDefaultHeaderText => '剧透'; + + @override + String get markAllAsReadLabel => '将所有消息标为已读'; + + @override + String markAsReadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 条消息', + ); + return '已将 $_temp0标为已读。'; + } + + @override + String get markAsReadInProgress => '正在将消息标为已读…'; + + @override + String get errorMarkAsReadFailedTitle => '未能将消息标为已读'; + + @override + String markAsUnreadComplete(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num 条消息', + ); + return '已将 $_temp0标为未读。'; + } + + @override + String get markAsUnreadInProgress => '正在将消息标为未读…'; + + @override + String get errorMarkAsUnreadFailedTitle => '未能将消息标为未读'; + + @override + String get today => '今天'; + + @override + String get yesterday => '昨天'; + + @override + String get userRoleOwner => '所有者'; + + @override + String get userRoleAdministrator => '管理员'; + + @override + String get userRoleModerator => '版主'; + + @override + String get userRoleMember => '成员'; + + @override + String get userRoleGuest => '访客'; + + @override + String get userRoleUnknown => '未知'; + + @override + String get inboxPageTitle => '收件箱'; + + @override + String get inboxEmptyPlaceholder => '你的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。'; + + @override + String get recentDmConversationsPageTitle => '私信'; + + @override + String get recentDmConversationsSectionHeader => '私信'; + + @override + String get recentDmConversationsEmptyPlaceholder => '您还没有任何私信消息!何不开启一个新对话?'; + + @override + String get combinedFeedPageTitle => '综合消息'; + + @override + String get mentionsPageTitle => '@提及'; + + @override + String get starredMessagesPageTitle => '星标消息'; + + @override + String get channelsPageTitle => '频道'; + + @override + String get channelsEmptyPlaceholder => '您还没有订阅任何频道。'; + + @override + String get mainMenuMyProfile => '个人资料'; + + @override + String get topicsButtonLabel => '话题'; + + @override + String get channelFeedButtonTooltip => '频道订阅'; + + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers 个用户', + ); + return '$senderFullName向你和其他 $_temp0'; + } + + @override + String get pinnedSubscriptionsLabel => '置顶'; + + @override + String get unpinnedSubscriptionsLabel => '未置顶'; + + @override + String get notifSelfUser => '您'; + + @override + String get reactedEmojiSelfUser => '您'; + + @override + String onePersonTyping(String typist) { + return '$typist正在输入…'; + } + + @override + String twoPeopleTyping(String typist, String otherTypist) { + return '$typist和$otherTypist正在输入…'; + } + + @override + String get manyPeopleTyping => '多个用户正在输入…'; + + @override + String get wildcardMentionAll => '所有人'; + + @override + String get wildcardMentionEveryone => '所有人'; + + @override + String get wildcardMentionChannel => '频道'; + + @override + String get wildcardMentionStream => '频道'; + + @override + String get wildcardMentionTopic => '话题'; + + @override + String get wildcardMentionChannelDescription => '通知频道'; + + @override + String get wildcardMentionStreamDescription => '通知频道'; + + @override + String get wildcardMentionAllDmDescription => '通知收件人'; + + @override + String get wildcardMentionTopicDescription => '通知话题'; + + @override + String get messageIsEditedLabel => '已编辑'; + + @override + String get messageIsMovedLabel => '已移动'; + + @override + String get messageNotSentLabel => '消息未发送'; + + @override + String pollVoterNames(String voterNames) { + return '($voterNames)'; + } + + @override + String get themeSettingTitle => '主题'; + + @override + String get themeSettingDark => '深色'; + + @override + String get themeSettingLight => '浅色'; + + @override + String get themeSettingSystem => '系统'; + + @override + String get openLinksWithInAppBrowser => '使用内置浏览器打开链接'; + + @override + String get pollWidgetQuestionMissing => '无问题。'; + + @override + String get pollWidgetOptionsMissing => '该投票还没有任何选项。'; + + @override + String get initialAnchorSettingTitle => '设置消息起始位置于'; + + @override + String get initialAnchorSettingDescription => '您可以将消息的起始位置设置为第一条未读消息或者最新消息。'; + + @override + String get initialAnchorSettingFirstUnreadAlways => '第一条未读消息'; + + @override + String get initialAnchorSettingFirstUnreadConversations => + '在单个话题或私信中,从第一条未读消息开始;在其他情况下,从最新消息开始'; + + @override + String get initialAnchorSettingNewestAlways => '最新消息'; + + @override + String get experimentalFeatureSettingsPageTitle => '实验功能'; + + @override + String get experimentalFeatureSettingsWarning => + '以下选项启用了一些正在开发中的功能。它们可能不能正常使用,或造成一些其他的问题。\n\n这些选项能够帮助开发者更好的试验这些功能。'; + + @override + String get errorNotificationOpenTitle => '未能打开消息提醒'; + + @override + String get errorNotificationOpenAccountNotFound => '未能找到关联该消息提醒的账号。'; + + @override + String get errorReactionAddingFailedTitle => '未能添加表情符号'; + + @override + String get errorReactionRemovingFailedTitle => '未能移除表情符号'; + + @override + String get emojiReactionsMore => '更多'; + + @override + String get emojiPickerSearchEmoji => '搜索表情符号'; + + @override + String get noEarlierMessages => '没有更早的消息了'; + + @override + String get mutedSender => '静音发送者'; + + @override + String get revealButtonLabel => '显示静音用户发送的消息'; + + @override + String get mutedUser => '静音用户'; + + @override + String get scrollToBottomTooltip => '拖动到最底'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; } /// The translations for Chinese, as used in Taiwan, using the Han script (`zh_Hant_TW`). class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { ZulipLocalizationsZhHantTw() : super('zh_Hant_TW'); + @override + String get aboutPageTitle => '關於 Zulip'; + + @override + String get aboutPageAppVersion => 'App 版本'; + + @override + String get aboutPageOpenSourceLicenses => '開源軟體授權條款'; + + @override + String get aboutPageTapToView => '點選查看'; + + @override + String get chooseAccountPageTitle => '選取帳號'; + @override String get settingsPageTitle => '設定'; + + @override + String get switchAccountButton => '切換帳號'; + + @override + String tryAnotherAccountMessage(Object url) { + return '你在 $url 的帳號載入的比較久'; + } + + @override + String get tryAnotherAccountButton => '請嘗試別的帳號'; + + @override + String get chooseAccountPageLogOutButton => '登出'; + + @override + String get logOutConfirmationDialogTitle => '登出?'; + + @override + String get logOutConfirmationDialogConfirmButton => '登出'; + + @override + String get chooseAccountButtonAddAnAccount => '新增帳號'; + + @override + String get profileButtonSendDirectMessage => '發送私訊'; + + @override + String get permissionsNeededTitle => '需要的權限'; + + @override + String get permissionsNeededOpenSettings => '開啟設定'; + + @override + String get actionSheetOptionMarkChannelAsRead => '標註頻道已讀'; + + @override + String get actionSheetOptionListOfTopics => '主題列表'; + + @override + String get actionSheetOptionMuteTopic => '將主題設為靜音'; + + @override + String get actionSheetOptionUnmuteTopic => '將主題取消靜音'; + + @override + String get actionSheetOptionResolveTopic => '標註為解決了'; + + @override + String get actionSheetOptionUnresolveTopic => '標註為未解決'; + + @override + String get errorResolveTopicFailedTitle => '無法標註為解決了'; + + @override + String get errorUnresolveTopicFailedTitle => '無法標註為未解決'; + + @override + String get actionSheetOptionCopyMessageText => '複製訊息文字'; + + @override + String get actionSheetOptionCopyMessageLink => '複製訊息連結'; + + @override + String get actionSheetOptionMarkAsUnread => '從這裡開始註記為未讀'; + + @override + String get actionSheetOptionShare => '分享'; + + @override + String get actionSheetOptionQuoteAndReply => '引用並回覆'; + + @override + String get actionSheetOptionStarMessage => '標註為重要訊息'; + + @override + String get actionSheetOptionUnstarMessage => '取消標註為重要訊息'; + + @override + String get actionSheetOptionEditMessage => '編輯訊息'; + + @override + String get actionSheetOptionMarkTopicAsRead => '標註主題為已讀'; + + @override + String get errorWebAuthOperationalErrorTitle => '出錯了'; + + @override + String get errorWebAuthOperationalError => '出現了意外的錯誤。'; + + @override + String get errorAccountLoggedInTitle => '帳號已經登入了'; + + @override + String errorAccountLoggedIn(String email, String server) { + return '在 $server 的帳號 $email 已經存在帳號清單中。'; + } } From a3313ecb8efdabb119c20d59445b7438a4a8efee Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 13 Jun 2025 00:03:16 -0700 Subject: [PATCH 193/290] version: Sync version and changelog from v0.0.32 release --- docs/changelog.md | 38 ++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 3dcb4c84ab..53c33e91d5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,44 @@ ## Unreleased +## 0.0.32 (2025-06-12) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. + +In addition to all the features in the last beta: +* The keyboard opens immediately when you start a + new conversation. (#1543) +* Translation updates, including new near-complete translations + for Slovenian (sl) and Chinese (Simplified, China) (zh_Hans_CN). +* Several small improvements to the newest features: + muted users (#296), message links going directly to message (#82). + + +### Highlights for developers + +* User-visible changes not described above: + * upgraded Flutter and deps (PR #1568) + * suppress long-press on muted-sender message, + and hide muted users in new-DM list (part of #296) + * reject internal links with malformed /near/ operands + (part of #82) + +* Resolved in main: #276 (though external to the tree), + #1543, #82, #80, #1147, #1441 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + ## 0.0.31 (2025-06-11) This is a preview beta, including some experimental changes diff --git a/pubspec.yaml b/pubspec.yaml index c5777527c2..54ddc70092 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.31+31 +version: 0.0.32+32 environment: # We use a recent version of Flutter from its main channel, and From a0241e0155ce0d253207cb2778a72b6f7de50c7d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Jun 2025 00:33:48 -0700 Subject: [PATCH 194/290] msglist: Say "Quote message" for the quote-and-reply button, following web Thanks Alya for pointing this out: https://chat.zulip.org/#narrow/channel/48-mobile/topic/quote.20and.20reply.20-.3E.20quote.20message/near/2193484 --- assets/l10n/app_en.arb | 6 +++--- lib/generated/l10n/zulip_localizations.dart | 6 +++--- lib/generated/l10n/zulip_localizations_ar.dart | 2 +- lib/generated/l10n/zulip_localizations_de.dart | 2 +- lib/generated/l10n/zulip_localizations_en.dart | 2 +- lib/generated/l10n/zulip_localizations_it.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/generated/l10n/zulip_localizations_sl.dart | 2 +- lib/generated/l10n/zulip_localizations_uk.dart | 2 +- lib/generated/l10n/zulip_localizations_zh.dart | 8 +------- lib/widgets/action_sheet.dart | 2 +- 15 files changed, 19 insertions(+), 25 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 0d3f273d16..e20d220aba 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -140,9 +140,9 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Quote and reply", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." + "actionSheetOptionQuoteMessage": "Quote message", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." }, "actionSheetOptionStarMessage": "Star message", "@actionSheetOptionStarMessage": { diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index fe3bac3607..7f6ed24d64 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -339,11 +339,11 @@ abstract class ZulipLocalizations { /// **'Share'** String get actionSheetOptionShare; - /// Label for Quote and reply button on action sheet. + /// Label for the 'Quote message' button in the message action sheet. /// /// In en, this message translates to: - /// **'Quote and reply'** - String get actionSheetOptionQuoteAndReply; + /// **'Quote message'** + String get actionSheetOptionQuoteMessage; /// Label for star button on action sheet. /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 2910711c42..5965ddf7a9 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -120,7 +120,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 832b1f05fc..d7120eb264 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -120,7 +120,7 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 9f41726924..6830703b27 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -120,7 +120,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 9dd440121e..84f094451e 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -120,7 +120,7 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 7d800ac7a8..5526921c24 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -120,7 +120,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 5d6c814002..5751493e04 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -120,7 +120,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index efa03e9f48..89d9be82b0 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -126,7 +126,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionShare => 'Udostępnij'; @override - String get actionSheetOptionQuoteAndReply => 'Odpowiedz cytując'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Oznacz gwiazdką'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 9e3777df75..a7f088b027 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -126,7 +126,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionShare => 'Поделиться'; @override - String get actionSheetOptionQuoteAndReply => 'Ответить с цитированием'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Отметить сообщение'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 51aace2d53..6e8365012e 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -121,7 +121,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get actionSheetOptionShare => 'Zdielať'; @override - String get actionSheetOptionQuoteAndReply => 'Citovať a odpovedať'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Ohviezdičkovať správu'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 0309756c05..89dd4d28ca 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -125,7 +125,7 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get actionSheetOptionShare => 'Deli'; @override - String get actionSheetOptionQuoteAndReply => 'Citiraj in odgovori'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Označi sporočilo z zvezdico'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 735276940b..5f8f37f617 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -126,7 +126,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionShare => 'Поширити'; @override - String get actionSheetOptionQuoteAndReply => 'Цитата і відповідь'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Вибрати повідомлення'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 5190c406a4..bbe12e4038 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -120,7 +120,7 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get actionSheetOptionShare => 'Share'; @override - String get actionSheetOptionQuoteAndReply => 'Quote and reply'; + String get actionSheetOptionQuoteMessage => 'Quote message'; @override String get actionSheetOptionStarMessage => 'Star message'; @@ -950,9 +950,6 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { @override String get actionSheetOptionShare => '分享'; - @override - String get actionSheetOptionQuoteAndReply => '引用消息并回复'; - @override String get actionSheetOptionStarMessage => '添加星标消息标记'; @@ -1730,9 +1727,6 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get actionSheetOptionShare => '分享'; - @override - String get actionSheetOptionQuoteAndReply => '引用並回覆'; - @override String get actionSheetOptionStarMessage => '標註為重要訊息'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6bd4e1024a..5c29b590de 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -833,7 +833,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override String label(ZulipLocalizations zulipLocalizations) { - return zulipLocalizations.actionSheetOptionQuoteAndReply; + return zulipLocalizations.actionSheetOptionQuoteMessage; } @override void onPressed() async { From 46671b4be2877aa97f22076c78328951ae85e136 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Jun 2025 22:58:38 -0700 Subject: [PATCH 195/290] msglist: Implement mark-read-on-scroll, without yet enabling When we add the setting for this, coming up, this long-awaited feature will become active. Hooray! This still needs tests. We're tracking that as #1583 for early post-launch. (The launch is coming up very soon.) --- lib/model/message.dart | 94 +++++++++++++++++- lib/model/message_list.dart | 11 +++ lib/model/store.dart | 3 + lib/widgets/action_sheet.dart | 6 +- lib/widgets/message_list.dart | 178 ++++++++++++++++++++++++++++++++++ 5 files changed, 289 insertions(+), 3 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 1dfe421368..9e9e45ca6a 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,10 +1,11 @@ import 'dart:async'; -import 'dart:collection'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; +import '../api/exception.dart'; import '../api/model/events.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; @@ -28,6 +29,8 @@ mixin MessageStore { void registerMessageList(MessageListView view); void unregisterMessageList(MessageListView view); + void markReadFromScroll(Iterable messageIds); + Future sendMessage({ required MessageDestination destination, required String content, @@ -180,6 +183,67 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes _disposed = true; } + static const _markReadOnScrollBatchSize = 1000; + static const _markReadOnScrollDebounceDuration = Duration(milliseconds: 500); + final _markReadOnScrollQueue = _MarkReadOnScrollQueue(); + bool _markReadOnScrollBusy = false; + + /// Returns true on success, false on failure. + Future _sendMarkReadOnScrollRequest(List toSend) async { + assert(toSend.isNotEmpty); + + // TODO(#1581) mark as read locally for latency compensation + // (in Unreads and on the message objects) + try { + await updateMessageFlags(connection, + messages: toSend, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read); + } on ApiRequestException { + // TODO(#1581) un-mark as read locally? + return false; + } + return true; + } + + @override + void markReadFromScroll(Iterable messageIds) async { + assert(!_disposed); + _markReadOnScrollQueue.addAll(messageIds); + if (_markReadOnScrollBusy) return; + + _markReadOnScrollBusy = true; + try { + do { + final toSend = []; + int numFromQueue = 0; + for (final messageId in _markReadOnScrollQueue.iterable) { + if (toSend.length == _markReadOnScrollBatchSize) { + break; + } + final message = messages[messageId]; + if (message != null && !message.flags.contains(MessageFlag.read)) { + toSend.add(message.id); + } + numFromQueue++; + } + + if (toSend.isEmpty || await _sendMarkReadOnScrollRequest(toSend)) { + if (_disposed) return; + _markReadOnScrollQueue.removeFirstN(numFromQueue); + } + if (_disposed) return; + + await Future.delayed(_markReadOnScrollDebounceDuration); + if (_disposed) return; + } while (_markReadOnScrollQueue.isNotEmpty); + } finally { + if (!_disposed) { + _markReadOnScrollBusy = false; + } + } + } + @override Future sendMessage({required MessageDestination destination, required String content}) { assert(!_disposed); @@ -517,6 +581,34 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes } } +class _MarkReadOnScrollQueue { + _MarkReadOnScrollQueue(); + + bool get isNotEmpty => _queue.isNotEmpty; + + final _set = {}; + final _queue = QueueList(); + + /// Add [messageIds] to the end of the queue, + /// if they aren't already in the queue. + void addAll(Iterable messageIds) { + for (final messageId in messageIds) { + if (_set.add(messageId)) { + _queue.add(messageId); + } + } + } + + Iterable get iterable => _queue; + + void removeFirstN(int n) { + for (int i = 0; i < n; i++) { + if (_queue.isEmpty) break; + _set.remove(_queue.removeFirst()); + } + } +} + /// The duration an outbox message stays hidden to the user. /// /// See [OutboxMessageState.waiting]. diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index a7aff0dcbc..f30d7fac0a 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -222,6 +222,17 @@ mixin _MessageSequence { return binarySearchByKey(items, messageId, _compareItemToMessageId); } + Iterable? getMessagesRange(int firstMessageId, int lastMessageId) { + assert(firstMessageId <= lastMessageId); + final firstIndex = _findMessageWithId(firstMessageId); + final lastIndex = _findMessageWithId(lastMessageId); + if (firstIndex == -1 || lastIndex == -1) { + // TODO(log) + return null; + } + return messages.getRange(firstIndex, lastIndex + 1); + } + static int _compareItemToMessageId(MessageListItem item, int messageId) { switch (item) { case MessageListRecipientHeaderItem(:var message): diff --git a/lib/model/store.dart b/lib/model/store.dart index 5171807a8e..8fad731f5c 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -758,6 +758,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor void unregisterMessageList(MessageListView view) => _messages.unregisterMessageList(view); @override + void markReadFromScroll(Iterable messageIds) => + _messages.markReadFromScroll(messageIds); + @override Future sendMessage({required MessageDestination destination, required String content}) { assert(!_disposed); return _messages.sendMessage(destination: destination, content: content); diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 5c29b590de..a78ba323c7 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -896,9 +896,11 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { } @override void onPressed() async { - final narrow = findMessageListPage().narrow; + final messageListPage = findMessageListPage(); unawaited(ZulipAction.markNarrowAsUnreadFromMessage(pageContext, - message, narrow)); + message, messageListPage.narrow)); + // TODO should we alert the user about this change somehow? A snackbar? + messageListPage.markReadOnScroll = false; } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 14c33ad5fd..3594d615d4 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/database.dart'; import '../model/message.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; @@ -139,6 +140,14 @@ abstract class MessageListPageState { /// /// This is null if [MessageList] has not mounted yet. MessageListView? get model; + + /// This view's decision whether to mark read on scroll, + /// overriding [GlobalSettings.markReadOnScroll]. + /// + /// For example, this is set to false after pressing + /// "Mark as unread from here" in the message action sheet. + bool? get markReadOnScroll; + set markReadOnScroll(bool? value); } class MessageListPage extends StatefulWidget { @@ -172,6 +181,32 @@ class MessageListPage extends StatefulWidget { @override State createState() => _MessageListPageState(); + + /// In debug mode, controls whether mark-read-on-scroll is enabled, + /// overriding [GlobalSettings.markReadOnScroll] + /// and [MessageListPageState.markReadOnScroll]. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugEnableMarkReadOnScroll { + bool result = true; + assert(() { + result = _debugEnableMarkReadOnScroll; + return true; + }()); + return result; + } + static bool _debugEnableMarkReadOnScroll = true; + static set debugEnableMarkReadOnScroll(bool value) { + assert(() { + _debugEnableMarkReadOnScroll = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + _debugEnableMarkReadOnScroll = true; + } } class _MessageListPageState extends State implements MessageListPageState { @@ -186,6 +221,16 @@ class _MessageListPageState extends State implements MessageLis MessageListView? get model => _messageListKey.currentState?.model; final GlobalKey<_MessageListState> _messageListKey = GlobalKey(); + @override + bool? get markReadOnScroll => _markReadOnScroll; + bool? _markReadOnScroll; + @override + set markReadOnScroll(bool? value) { + setState(() { + _markReadOnScroll = value; + }); + } + @override void initState() { super.initState(); @@ -298,6 +343,7 @@ class _MessageListPageState extends State implements MessageLis narrow: narrow, initAnchor: initAnchor, onNarrowChanged: _narrowChanged, + markReadOnScroll: markReadOnScroll, ))), if (ComposeBox.hasComposeBox(narrow)) ComposeBox(key: _composeBoxKey, narrow: narrow) @@ -503,17 +549,21 @@ class MessageList extends StatefulWidget { required this.narrow, required this.initAnchor, required this.onNarrowChanged, + required this.markReadOnScroll, }); final Narrow narrow; final Anchor initAnchor; final void Function(Narrow newNarrow) onNarrowChanged; + final bool? markReadOnScroll; @override State createState() => _MessageListState(); } class _MessageListState extends State with PerAccountStoreAwareStateMixin { + final GlobalKey _scrollViewKey = GlobalKey(); + MessageListView get model => _model!; MessageListView? _model; @@ -552,6 +602,17 @@ class _MessageListState extends State with PerAccountStoreAwareStat bool _prevFetched = false; void _modelChanged() { + // When you're scrolling quickly, our mark-as-read requests include the + // messages *between* _messagesRecentlyInViewport and the messages currently + // in view, so that messages don't get left out because you were scrolling + // so fast that they never rendered onscreen. + // + // Here, the onscreen messages might be totally different, + // and not because of scrolling; e.g. because the narrow changed. + // Avoid "filling in" a mark-as-read request with totally wrong messages, + // by forgetting the old range. + _messagesRecentlyInViewport = null; + if (model.narrow != widget.narrow) { // Either: // - A message move event occurred, where propagate mode is @@ -576,7 +637,122 @@ class _MessageListState extends State with PerAccountStoreAwareStat _prevFetched = model.fetched; } + /// Find the range of message IDs on screen, as a (first, last) tuple, + /// or null if no messages are onscreen. + /// + /// A message is considered onscreen if its bottom edge is in the viewport. + /// + /// Ignores outbox messages. + (int, int)? _findMessagesInViewport() { + final scrollViewElement = _scrollViewKey.currentContext as Element; + final scrollViewRenderObject = scrollViewElement.renderObject as RenderBox; + + int? first; + int? last; + void visit(Element element) { + final widget = element.widget; + switch (widget) { + case RecipientHeader(): + case DateSeparator(): + case MarkAsReadWidget(): + // MessageItems won't be descendants of these + return; + + case MessageItem(item: MessageListOutboxMessageItem()): + return; // ignore outbox + + case MessageItem(item: MessageListMessageItem(:final message)): + final isInViewport = _isMessageItemInViewport( + element, scrollViewRenderObject: scrollViewRenderObject); + if (isInViewport) { + if (first == null) { + assert(last == null); + first = message.id; + last = message.id; + return; + } + if (message.id < first!) { + first = message.id; + } + if (last! < message.id) { + last = message.id; + } + } + return; // no need to look for more MessageItems inside this one + + default: + element.visitChildElements(visit); + } + } + scrollViewElement.visitChildElements(visit); + + if (first == null) { + assert(last == null); + return null; + } + return (first!, last!); + } + + bool _isMessageItemInViewport( + Element element, { + required RenderBox scrollViewRenderObject, + }) { + assert(element.widget is MessageItem + && (element.widget as MessageItem).item is MessageListMessageItem); + final viewportHeight = scrollViewRenderObject.size.height; + + final messageRenderObject = element.renderObject as RenderBox; + + final messageBottom = messageRenderObject.localToGlobal( + Offset(0, messageRenderObject.size.height), + ancestor: scrollViewRenderObject).dy; + + return 0 < messageBottom && messageBottom <= viewportHeight; + } + + (int, int)? _messagesRecentlyInViewport; + + void _markReadFromScroll() { + final currentRange = _findMessagesInViewport(); + if (currentRange == null) return; + + final (currentFirst, currentLast) = currentRange; + final (prevFirst, prevLast) = _messagesRecentlyInViewport ?? (null, null); + + // ("Hull" as in the "convex hull" around the old and new ranges.) + final firstOfHull = switch ((prevFirst, currentFirst)) { + (int previous, int current) => previous < current ? previous : current, + ( _, int current) => current, + }; + + final lastOfHull = switch ((prevLast, currentLast)) { + (int previous, int current) => previous > current ? previous : current, + ( _, int current) => current, + }; + + final sublist = model.getMessagesRange(firstOfHull, lastOfHull); + if (sublist == null) { + _messagesRecentlyInViewport = null; + return; + } + model.store.markReadFromScroll(sublist.map((message) => message.id)); + + _messagesRecentlyInViewport = currentRange; + } + + bool _effectiveMarkReadOnScroll() { + if (!MessageListPage.debugEnableMarkReadOnScroll) return false; + return widget.markReadOnScroll + ?? false; + // TODO instead: + // ?? GlobalStoreWidget.settingsOf(context).markReadOnScrollForNarrow(widget.narrow); + } + void _handleScrollMetrics(ScrollMetrics scrollMetrics) { + if (_effectiveMarkReadOnScroll()) { + _markReadFromScroll(); + } + if (scrollMetrics.extentAfter == 0) { _scrollToBottomVisible.value = false; } else { @@ -745,6 +921,8 @@ class _MessageListState extends State with PerAccountStoreAwareStat } return MessageListScrollView( + key: _scrollViewKey, + // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or // similar) if that is ever offered: // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 From 301384cfa915c852c2a15c2c861964d769531ed9 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Jun 2025 09:43:48 -0700 Subject: [PATCH 196/290] msglist: Enable mark-read-on-scroll feature, with new global setting! This still needs tests. We're tracking this as #1583 for early post-launch. (The launch is coming up very soon.) Fixes: #81 --- assets/l10n/app_en.arb | 24 + lib/generated/l10n/zulip_localizations.dart | 36 + .../l10n/zulip_localizations_ar.dart | 21 + .../l10n/zulip_localizations_de.dart | 21 + .../l10n/zulip_localizations_en.dart | 21 + .../l10n/zulip_localizations_it.dart | 21 + .../l10n/zulip_localizations_ja.dart | 21 + .../l10n/zulip_localizations_nb.dart | 21 + .../l10n/zulip_localizations_pl.dart | 21 + .../l10n/zulip_localizations_ru.dart | 21 + .../l10n/zulip_localizations_sk.dart | 21 + .../l10n/zulip_localizations_sl.dart | 21 + .../l10n/zulip_localizations_uk.dart | 21 + .../l10n/zulip_localizations_zh.dart | 21 + lib/model/database.dart | 9 +- lib/model/database.g.dart | 112 +- lib/model/schema_versions.g.dart | 84 ++ lib/model/settings.dart | 47 + lib/widgets/message_list.dart | 4 +- lib/widgets/settings.dart | 81 ++ test/model/schemas/drift_schema_v8.json | 1 + test/model/schemas/schema.dart | 5 +- test/model/schemas/schema_v8.dart | 967 ++++++++++++++++++ test/model/settings_test.dart | 3 + test/widgets/action_sheet_test.dart | 1 + test/widgets/autocomplete_test.dart | 1 + test/widgets/compose_box_test.dart | 1 + test/widgets/emoji_reaction_test.dart | 1 + test/widgets/lightbox_test.dart | 1 + test/widgets/message_list_test.dart | 1 + 30 files changed, 1622 insertions(+), 9 deletions(-) create mode 100644 test/model/schemas/drift_schema_v8.json create mode 100644 test/model/schemas/schema_v8.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index e20d220aba..e03f421761 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -971,6 +971,30 @@ "@initialAnchorSettingNewestAlways": { "description": "Label for a value of setting controlling initial anchor of message list." }, + "markReadOnScrollSettingTitle": "Mark messages as read on scroll", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "When scrolling through messages, should they automatically be marked as read?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Always", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Never", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Only in conversation views", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "Messages will be automatically marked as read only when viewing a single topic or direct message conversation.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, "experimentalFeatureSettingsPageTitle": "Experimental features", "@experimentalFeatureSettingsPageTitle": { "description": "Title of settings page for experimental, in-development features" diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 7f6ed24d64..c13cdd3a9f 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1449,6 +1449,42 @@ abstract class ZulipLocalizations { /// **'Newest message'** String get initialAnchorSettingNewestAlways; + /// Title of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Mark messages as read on scroll'** + String get markReadOnScrollSettingTitle; + + /// Description of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'When scrolling through messages, should they automatically be marked as read?'** + String get markReadOnScrollSettingDescription; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Always'** + String get markReadOnScrollSettingAlways; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Never'** + String get markReadOnScrollSettingNever; + + /// Label for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Only in conversation views'** + String get markReadOnScrollSettingConversations; + + /// Description for a value of setting controlling which message-list views should mark read on scroll. + /// + /// In en, this message translates to: + /// **'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'** + String get markReadOnScrollSettingConversationsDescription; + /// Title of settings page for experimental, in-development features /// /// 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 5965ddf7a9..c47095ee06 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -790,6 +790,27 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index d7120eb264..301f70d34d 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -790,6 +790,27 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 6830703b27..52e4393767 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -790,6 +790,27 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 84f094451e..084eedfbbc 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -790,6 +790,27 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 5526921c24..1fdc2f585d 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -790,6 +790,27 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 5751493e04..4bdd16533d 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -790,6 +790,27 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 89d9be82b0..3943e5c01d 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -801,6 +801,27 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Funkcje eksperymentalne'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index a7f088b027..13a6729b9b 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -804,6 +804,27 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Экспериментальные функции'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 6e8365012e..29e203cccb 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -792,6 +792,27 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 89dd4d28ca..c69ff12045 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -812,6 +812,27 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Eksperimentalne funkcije'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 5f8f37f617..6c5e7264d1 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -805,6 +805,27 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Експериментальні функції'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index bbe12e4038..d61e7cd6e3 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -790,6 +790,27 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get initialAnchorSettingNewestAlways => 'Newest message'; + @override + String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + + @override + String get markReadOnScrollSettingDescription => + 'When scrolling through messages, should they automatically be marked as read?'; + + @override + String get markReadOnScrollSettingAlways => 'Always'; + + @override + String get markReadOnScrollSettingNever => 'Never'; + + @override + String get markReadOnScrollSettingConversations => + 'Only in conversation views'; + + @override + String get markReadOnScrollSettingConversationsDescription => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + @override String get experimentalFeatureSettingsPageTitle => 'Experimental features'; diff --git a/lib/model/database.dart b/lib/model/database.dart index f7d85b4b95..e20380b9e7 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -27,6 +27,9 @@ class GlobalSettings extends Table { Column get visitFirstUnread => textEnum() .nullable()(); + Column get markReadOnScroll => textEnum() + .nullable()(); + // If adding a new column to this table, consider whether [BoolGlobalSettings] // can do the job instead (by adding a value to the [BoolGlobalSetting] enum). // That way is more convenient, when it works, because @@ -122,7 +125,7 @@ class AppDatabase extends _$AppDatabase { // information on using the build_runner. // * Write a migration in `_migrationSteps` below. // * Write tests. - static const int latestSchemaVersion = 7; // See note. + static const int latestSchemaVersion = 8; // See note. @override int get schemaVersion => latestSchemaVersion; @@ -181,6 +184,10 @@ class AppDatabase extends _$AppDatabase { await m.addColumn(schema.globalSettings, schema.globalSettings.visitFirstUnread); }, + from7To8: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.markReadOnScroll); + }, ); Future _createLatestSchema(Migrator m) async { diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart index 9ff8b71b65..d78f7ede84 100644 --- a/lib/model/database.g.dart +++ b/lib/model/database.g.dart @@ -45,10 +45,23 @@ class $GlobalSettingsTable extends GlobalSettings $GlobalSettingsTable.$convertervisitFirstUnreadn, ); @override + late final GeneratedColumnWithTypeConverter + markReadOnScroll = + GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$convertermarkReadOnScrolln, + ); + @override List get $columns => [ themeSetting, browserPreference, visitFirstUnread, + markReadOnScroll, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -81,6 +94,13 @@ class $GlobalSettingsTable extends GlobalSettings data['${effectivePrefix}visit_first_unread'], ), ), + markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + ), ); } @@ -113,6 +133,14 @@ class $GlobalSettingsTable extends GlobalSettings $convertervisitFirstUnreadn = JsonTypeConverter2.asNullable( $convertervisitFirstUnread, ); + static JsonTypeConverter2 + $convertermarkReadOnScroll = const EnumNameConverter( + MarkReadOnScrollSetting.values, + ); + static JsonTypeConverter2 + $convertermarkReadOnScrolln = JsonTypeConverter2.asNullable( + $convertermarkReadOnScroll, + ); } class GlobalSettingsData extends DataClass @@ -120,10 +148,12 @@ class GlobalSettingsData extends DataClass final ThemeSetting? themeSetting; final BrowserPreference? browserPreference; final VisitFirstUnreadSetting? visitFirstUnread; + final MarkReadOnScrollSetting? markReadOnScroll; const GlobalSettingsData({ this.themeSetting, this.browserPreference, this.visitFirstUnread, + this.markReadOnScroll, }); @override Map toColumns(bool nullToAbsent) { @@ -147,6 +177,13 @@ class GlobalSettingsData extends DataClass ), ); } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toSql( + markReadOnScroll, + ), + ); + } return map; } @@ -161,6 +198,9 @@ class GlobalSettingsData extends DataClass visitFirstUnread: visitFirstUnread == null && nullToAbsent ? const Value.absent() : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), ); } @@ -177,6 +217,8 @@ class GlobalSettingsData extends DataClass .fromJson(serializer.fromJson(json['browserPreference'])), visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn .fromJson(serializer.fromJson(json['visitFirstUnread'])), + markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln + .fromJson(serializer.fromJson(json['markReadOnScroll'])), ); } @override @@ -196,6 +238,11 @@ class GlobalSettingsData extends DataClass visitFirstUnread, ), ), + 'markReadOnScroll': serializer.toJson( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toJson( + markReadOnScroll, + ), + ), }; } @@ -203,6 +250,7 @@ class GlobalSettingsData extends DataClass Value themeSetting = const Value.absent(), Value browserPreference = const Value.absent(), Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, browserPreference: browserPreference.present @@ -211,6 +259,9 @@ class GlobalSettingsData extends DataClass visitFirstUnread: visitFirstUnread.present ? visitFirstUnread.value : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( @@ -223,6 +274,9 @@ class GlobalSettingsData extends DataClass visitFirstUnread: data.visitFirstUnread.present ? data.visitFirstUnread.value : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, ); } @@ -231,50 +285,61 @@ class GlobalSettingsData extends DataClass return (StringBuffer('GlobalSettingsData(') ..write('themeSetting: $themeSetting, ') ..write('browserPreference: $browserPreference, ') - ..write('visitFirstUnread: $visitFirstUnread') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(themeSetting, browserPreference, visitFirstUnread); + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + ); @override bool operator ==(Object other) => identical(this, other) || (other is GlobalSettingsData && other.themeSetting == this.themeSetting && other.browserPreference == this.browserPreference && - other.visitFirstUnread == this.visitFirstUnread); + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll); } class GlobalSettingsCompanion extends UpdateCompanion { final Value themeSetting; final Value browserPreference; final Value visitFirstUnread; + final Value markReadOnScroll; final Value rowid; const GlobalSettingsCompanion({ this.themeSetting = const Value.absent(), this.browserPreference = const Value.absent(), this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), this.rowid = const Value.absent(), }); GlobalSettingsCompanion.insert({ this.themeSetting = const Value.absent(), this.browserPreference = const Value.absent(), this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), this.rowid = const Value.absent(), }); static Insertable custom({ Expression? themeSetting, Expression? browserPreference, Expression? visitFirstUnread, + Expression? markReadOnScroll, Expression? rowid, }) { return RawValuesInsertable({ if (themeSetting != null) 'theme_setting': themeSetting, if (browserPreference != null) 'browser_preference': browserPreference, if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, if (rowid != null) 'rowid': rowid, }); } @@ -283,12 +348,14 @@ class GlobalSettingsCompanion extends UpdateCompanion { Value? themeSetting, Value? browserPreference, Value? visitFirstUnread, + Value? markReadOnScroll, Value? rowid, }) { return GlobalSettingsCompanion( themeSetting: themeSetting ?? this.themeSetting, browserPreference: browserPreference ?? this.browserPreference, visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, rowid: rowid ?? this.rowid, ); } @@ -315,6 +382,13 @@ class GlobalSettingsCompanion extends UpdateCompanion { ), ); } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable( + $GlobalSettingsTable.$convertermarkReadOnScrolln.toSql( + markReadOnScroll.value, + ), + ); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -327,6 +401,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { ..write('themeSetting: $themeSetting, ') ..write('browserPreference: $browserPreference, ') ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -1172,6 +1247,7 @@ typedef $$GlobalSettingsTableCreateCompanionBuilder = Value themeSetting, Value browserPreference, Value visitFirstUnread, + Value markReadOnScroll, Value rowid, }); typedef $$GlobalSettingsTableUpdateCompanionBuilder = @@ -1179,6 +1255,7 @@ typedef $$GlobalSettingsTableUpdateCompanionBuilder = Value themeSetting, Value browserPreference, Value visitFirstUnread, + Value markReadOnScroll, Value rowid, }); @@ -1212,6 +1289,16 @@ class $$GlobalSettingsTableFilterComposer column: $table.visitFirstUnread, builder: (column) => ColumnWithTypeConverterFilters(column), ); + + ColumnWithTypeConverterFilters< + MarkReadOnScrollSetting?, + MarkReadOnScrollSetting, + String + > + get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); } class $$GlobalSettingsTableOrderingComposer @@ -1237,6 +1324,11 @@ class $$GlobalSettingsTableOrderingComposer column: $table.visitFirstUnread, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => ColumnOrderings(column), + ); } class $$GlobalSettingsTableAnnotationComposer @@ -1265,6 +1357,12 @@ class $$GlobalSettingsTableAnnotationComposer column: $table.visitFirstUnread, builder: (column) => column, ); + + GeneratedColumnWithTypeConverter + get markReadOnScroll => $composableBuilder( + column: $table.markReadOnScroll, + builder: (column) => column, + ); } class $$GlobalSettingsTableTableManager @@ -1309,11 +1407,14 @@ class $$GlobalSettingsTableTableManager const Value.absent(), Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = + const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion( themeSetting: themeSetting, browserPreference: browserPreference, visitFirstUnread: visitFirstUnread, + markReadOnScroll: markReadOnScroll, rowid: rowid, ), createCompanionCallback: @@ -1323,11 +1424,14 @@ class $$GlobalSettingsTableTableManager const Value.absent(), Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = + const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion.insert( themeSetting: themeSetting, browserPreference: browserPreference, visitFirstUnread: visitFirstUnread, + markReadOnScroll: markReadOnScroll, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart index 4fcfc67a06..5712a94fbb 100644 --- a/lib/model/schema_versions.g.dart +++ b/lib/model/schema_versions.g.dart @@ -441,6 +441,82 @@ i1.GeneratedColumn _column_13(String aliasedName) => true, type: i1.DriftSqlType.string, ); + +final class Schema8 extends i0.VersionedSchema { + Schema8({required super.database}) : super(version: 8); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape5 globalSettings = Shape5( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + 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 Shape5 extends i0.VersionedTable { + Shape5({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; + i1.GeneratedColumn get markReadOnScroll => + columnsByName['mark_read_on_scroll']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_14(String aliasedName) => + i1.GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -448,6 +524,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -481,6 +558,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from6To7(migrator, schema); return 7; + case 7: + final schema = Schema8(database: database); + final migrator = i1.Migrator(database, schema); + await from7To8(migrator, schema); + return 8; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -494,6 +576,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -502,5 +585,6 @@ i1.OnUpgrade stepByStep({ from4To5: from4To5, from5To6: from5To6, from6To7: from6To7, + from7To8: from7To8, ), ); diff --git a/lib/model/settings.dart b/lib/model/settings.dart index d3393292a6..298980a395 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -67,6 +67,26 @@ enum VisitFirstUnreadSetting { static VisitFirstUnreadSetting _default = conversations; } +/// The user's choice of which message-list views should +/// automatically mark messages as read when scrolling through them. +/// +/// This can be overridden by local state: for example, if you've just tapped +/// "Mark as unread from here" the view will stop marking as read automatically, +/// regardless of this setting. +enum MarkReadOnScrollSetting { + /// All views. + always, + + /// Only conversation views. + conversations, + + /// No views. + never; + + /// The effective value of this setting if the user hasn't set it. + static MarkReadOnScrollSetting _default = conversations; +} + /// A general category of account-independent setting the user might set. /// /// Different kinds of settings call for different treatment in the UI, @@ -277,6 +297,33 @@ class GlobalSettingsStore extends ChangeNotifier { }; } + /// The user's choice of [MarkReadOnScrollSetting], applying our default. + /// + /// See also [markReadOnScrollForNarrow] and [setMarkReadOnScroll]. + MarkReadOnScrollSetting get markReadOnScroll { + return _data.markReadOnScroll ?? MarkReadOnScrollSetting._default; + } + + /// Set [markReadOnScroll], persistently for future runs of the app. + Future setMarkReadOnScroll(MarkReadOnScrollSetting value) async { + await _update(GlobalSettingsCompanion(markReadOnScroll: Value(value))); + } + + /// The value that [markReadOnScroll] works out to for the given narrow. + bool markReadOnScrollForNarrow(Narrow narrow) { + return switch (markReadOnScroll) { + MarkReadOnScrollSetting.always => true, + MarkReadOnScrollSetting.never => false, + MarkReadOnScrollSetting.conversations => switch (narrow) { + TopicNarrow() || DmNarrow() + => true, + CombinedFeedNarrow() || ChannelNarrow() + || MentionsNarrow() || StarredMessagesNarrow() + => false, + }, + }; + } + /// The user's choice of the given bool-valued setting, or our default for it. /// /// See also [setBool]. diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 3594d615d4..75c8b5cee0 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -743,9 +743,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat bool _effectiveMarkReadOnScroll() { if (!MessageListPage.debugEnableMarkReadOnScroll) return false; return widget.markReadOnScroll - ?? false; - // TODO instead: - // ?? GlobalStoreWidget.settingsOf(context).markReadOnScrollForNarrow(widget.narrow); + ?? GlobalStoreWidget.settingsOf(context).markReadOnScrollForNarrow(widget.narrow); } void _handleScrollMetrics(ScrollMetrics scrollMetrics) { diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 449be11313..394415a8be 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -24,6 +24,7 @@ class SettingsPage extends StatelessWidget { const _ThemeSetting(), const _BrowserPreferenceSetting(), const _VisitFirstUnreadSetting(), + const _MarkReadOnScrollSetting(), if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty) ListTile( title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), @@ -150,6 +151,86 @@ class VisitFirstUnreadSettingPage extends StatelessWidget { } } +class _MarkReadOnScrollSetting extends StatelessWidget { + const _MarkReadOnScrollSetting(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return ListTile( + title: Text(zulipLocalizations.markReadOnScrollSettingTitle), + subtitle: Text(MarkReadOnScrollSettingPage._valueDisplayName( + globalSettings.markReadOnScroll, zulipLocalizations: zulipLocalizations)), + onTap: () => Navigator.push(context, + MarkReadOnScrollSettingPage.buildRoute())); + } +} + +class MarkReadOnScrollSettingPage extends StatelessWidget { + const MarkReadOnScrollSettingPage({super.key}); + + static WidgetRoute buildRoute() { + return MaterialWidgetRoute(page: const MarkReadOnScrollSettingPage()); + } + + static String _valueDisplayName(MarkReadOnScrollSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + MarkReadOnScrollSetting.always => + zulipLocalizations.markReadOnScrollSettingAlways, + MarkReadOnScrollSetting.conversations => + zulipLocalizations.markReadOnScrollSettingConversations, + MarkReadOnScrollSetting.never => + zulipLocalizations.markReadOnScrollSettingNever, + }; + } + + static String? _valueDescription(MarkReadOnScrollSetting value, { + required ZulipLocalizations zulipLocalizations, + }) { + return switch (value) { + MarkReadOnScrollSetting.always => null, + MarkReadOnScrollSetting.conversations => + zulipLocalizations.markReadOnScrollSettingConversationsDescription, + MarkReadOnScrollSetting.never => null, + }; + } + + void _handleChange(BuildContext context, MarkReadOnScrollSetting? value) { + if (value == null) return; // TODO(log); can this actually happen? how? + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setMarkReadOnScroll(value); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); + return Scaffold( + appBar: AppBar(title: Text(zulipLocalizations.markReadOnScrollSettingTitle)), + body: Column(children: [ + ListTile(title: Text(zulipLocalizations.markReadOnScrollSettingDescription)), + for (final value in MarkReadOnScrollSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + subtitle: () { + final result = _valueDescription(value, + zulipLocalizations: zulipLocalizations); + return result == null ? null : Text(result); + }(), + value: value, + // TODO(#1545) stop using the deprecated members + // ignore: deprecated_member_use + groupValue: globalSettings.markReadOnScroll, + // ignore: deprecated_member_use + onChanged: (newValue) => _handleChange(context, newValue)), + ])); + } +} + class ExperimentalFeaturesPage extends StatelessWidget { const ExperimentalFeaturesPage({super.key}); diff --git a/test/model/schemas/drift_schema_v8.json b/test/model/schemas/drift_schema_v8.json new file mode 100644 index 0000000000..62f8ca43d0 --- /dev/null +++ b/test/model/schemas/drift_schema_v8.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart index 87de9194d3..746206e453 100644 --- a/test/model/schemas/schema.dart +++ b/test/model/schemas/schema.dart @@ -10,6 +10,7 @@ import 'schema_v4.dart' as v4; import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; +import 'schema_v8.dart' as v8; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -29,10 +30,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v6.DatabaseAtV6(db); case 7: return v7.DatabaseAtV7(db); + case 8: + return v8.DatabaseAtV8(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8]; } diff --git a/test/model/schemas/schema_v8.dart b/test/model/schemas/schema_v8.dart new file mode 100644 index 0000000000..fb17863b15 --- /dev/null +++ b/test/model/schemas/schema_v8.dart @@ -0,0 +1,967 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV8 extends GeneratedDatabase { + DatabaseAtV8(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 8; +} diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart index 89956323e2..b4842ecd04 100644 --- a/test/model/settings_test.dart +++ b/test/model/settings_test.dart @@ -80,6 +80,9 @@ void main() { // TODO(#1571) test visitFirstUnread applies default // TODO(#1571) test shouldVisitFirstUnread + // TODO(#1583) test markReadOnScroll applies default + // TODO(#1583) test markReadOnScrollForNarrow + group('getBool/setBool', () { test('get from default', () { final globalSettings = eg.globalStore(boolGlobalSettings: {}).settings; diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 16cc36b096..ebb6cb9b71 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -114,6 +114,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { void main() { TestZulipBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; void prepareRawContentResponseSuccess({ required Message message, diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index b4ff007a8d..573921b663 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -145,6 +145,7 @@ typedef ExpectedEmoji = (String label, EmojiDisplay display); void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; group('@-mentions', () { diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 70f0913316..76305610c6 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -43,6 +43,7 @@ import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index de3ad7227c..9ff4849b1b 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -36,6 +36,7 @@ import 'text_test.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index fda7122123..3165222c45 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -203,6 +203,7 @@ class FakeVideoPlayerPlatform extends Fake void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; group('LightboxHero', () { late PerAccountStore store; diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f048bf437d..fd8dd6f10b 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -52,6 +52,7 @@ import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + MessageListPage.debugEnableMarkReadOnScroll = false; late PerAccountStore store; late FakeApiConnection connection; From 0a19789f4f63d90698746805c2f667b9da21267b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 13 Jun 2025 23:42:13 -0700 Subject: [PATCH 197/290] docs/release: Document using the bot to update translations from Weblate This is how I've done the last several releases. It's convenient. --- docs/release.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/release.md b/docs/release.md index 7895ba50b0..7336e4e04f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,8 +6,13 @@ 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. +* Update translations from Weblate: + * Run the [GitHub action][weblate-github-action] to create a PR + (or update an existing bot PR) with translation updates. + * CI doesn't run on the bot's PRs. So if you suspect the PR might + break anything (e.g. if this is the first sync since changing + something in our Weblate setup), run `tools/check` on it yourself. + * Merge the PR. * Write an entry in `docs/changelog.md`, under "Unreleased". Commit that change. @@ -15,6 +20,8 @@ * Run `tools/bump-version` to update the version number. Inspect the resulting commit and tag, and push. +[weblate-github-action]: https://github.com/zulip/zulip-flutter/actions/workflows/update-translations.yml + ## Build and upload alpha: Android From 25f91b75ad6d01bc24cc904a4912acf151750187 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Sat, 14 Jun 2025 07:54:37 +0200 Subject: [PATCH 198/290] l10n: Update translations from Weblate. --- assets/l10n/app_it.arb | 291 +++++++++++++++++- assets/l10n/app_pl.arb | 44 ++- assets/l10n/app_ru.arb | 28 +- assets/l10n/app_sk.arb | 4 - assets/l10n/app_sl.arb | 4 - assets/l10n/app_uk.arb | 4 - assets/l10n/app_zh_Hans_CN.arb | 4 - assets/l10n/app_zh_Hant_TW.arb | 4 - .../l10n/zulip_localizations_it.dart | 122 ++++---- .../l10n/zulip_localizations_pl.dart | 22 +- .../l10n/zulip_localizations_ru.dart | 13 +- 11 files changed, 436 insertions(+), 104 deletions(-) diff --git a/assets/l10n/app_it.arb b/assets/l10n/app_it.arb index 0967ef424b..d417244e91 100644 --- a/assets/l10n/app_it.arb +++ b/assets/l10n/app_it.arb @@ -1 +1,290 @@ -{} +{ + "aboutPageTapToView": "Tap per visualizzare", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "settingsPageTitle": "Impostazioni", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "switchAccountButton": "Cambia account", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "tryAnotherAccountButton": "Prova un altro account", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "chooseAccountPageLogOutButton": "Esci", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "logOutConfirmationDialogTitle": "Disconnettersi?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogMessage": "Per utilizzare questo account in futuro, bisognerà reinserire l'URL della propria organizzazione e le informazioni del proprio account.", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "Esci", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "chooseAccountButtonAddAnAccount": "Aggiungi un account", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "errorCouldNotShowUserProfile": "Impossibile mostrare il profilo utente.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "permissionsNeededTitle": "Permessi necessari", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "permissionsNeededOpenSettings": "Apri le impostazioni", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "actionSheetOptionMarkChannelAsRead": "Segna il canale come letto", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionListOfTopics": "Elenco degli argomenti", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionUnfollowTopic": "Non seguire più l'argomento", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "aboutPageTitle": "Su Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page." + }, + "aboutPageAppVersion": "Versione app", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageOpenSourceLicenses": "Licenze open-source", + "@aboutPageOpenSourceLicenses": { + "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "chooseAccountPageTitle": "Scegli account", + "@chooseAccountPageTitle": { + "description": "Title for the page to choose between Zulip accounts." + }, + "actionSheetOptionFollowTopic": "Segui argomento", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "permissionsDeniedReadExternalStorage": "Per caricare file, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "tryAnotherAccountMessage": "Il caricamento dell'account su {url} sta richiedendo un po' di tempo.", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "actionSheetOptionMuteTopic": "Silenzia argomento", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "Riattiva argomento", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "profileButtonSendDirectMessage": "Invia un messaggio diretto", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "permissionsDeniedCameraAccess": "Per caricare un'immagine, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "actionSheetOptionResolveTopic": "Segna come risolto", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Impossibile contrassegnare l'argomento come risolto", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "errorUnresolveTopicFailedTitle": "Impossibile contrassegnare l'argomento come irrisolto", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "actionSheetOptionCopyMessageLink": "Copia il collegamento al messaggio", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "actionSheetOptionMarkAsUnread": "Segna come non letto da qui", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "actionSheetOptionHideMutedMessage": "Nascondi nuovamente il messaggio disattivato", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "actionSheetOptionEditMessage": "Modifica messaggio", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "errorAccountLoggedInTitle": "Account già registrato", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "errorLoginInvalidInputTitle": "Ingresso non valido", + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "errorLoginFailedTitle": "Accesso non riuscito", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorMessageEditNotSaved": "Messaggio non salvato", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorCouldNotConnectTitle": "Impossibile connettersi", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorMessageDoesNotSeemToExist": "Quel messaggio sembra non esistere.", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "errorQuotationFailed": "Citazione non riuscita", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "errorConnectingToServerShort": "Errore di connessione a Zulip. Nuovo tentativo…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorHandlingEventTitle": "Errore nella gestione di un evento Zulip. Nuovo tentativo di connessione…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "errorFailedToUploadFileTitle": "Impossibile caricare il file: {filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "errorCouldNotFetchMessageSource": "Impossibile recuperare l'origine del messaggio.", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "errorMessageNotSent": "Messaggio non inviato", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "actionSheetOptionShare": "Condividi", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionUnstarMessage": "Togli la stella dal messaggio", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "errorLoginCouldNotConnect": "Impossibile connettersi al server:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorWebAuthOperationalError": "Si è verificato un errore imprevisto.", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorAccountLoggedIn": "L'account {email} su {server} è già presente nell'elenco account.", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "errorServerMessage": "Il server ha detto:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorCopyingFailed": "Copia non riuscita", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "actionSheetOptionUnresolveTopic": "Segna come irrisolto", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "actionSheetOptionCopyMessageText": "Copia il testo del messaggio", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionStarMessage": "Metti una stella al messaggio", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionMarkTopicAsRead": "Segna l'argomento come letto", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "errorWebAuthOperationalErrorTitle": "Qualcosa è andato storto", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "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" + } + } + }, + "errorConnectingToServerDetails": "Errore durante la connessione a Zulip su {serverUrl}. Verrà effettuato un nuovo tentativo:\n\n{error}", + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "http://example.com/" + }, + "error": { + "type": "String", + "example": "Invalid format" + } + } + } +} diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 982ca98be4..acc8644b3d 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -53,10 +53,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Odpowiedz cytując", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "Oznacz gwiazdką", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." @@ -1116,5 +1112,45 @@ "errorNotificationOpenAccountNotFound": "Nie odnaleziono konta powiązanego z tym powiadomieniem.", "@errorNotificationOpenAccountNotFound": { "description": "Error message when the account associated with the notification could not be found" + }, + "newDmSheetComposeButtonLabel": "Utwórz", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "inboxEmptyPlaceholder": "Obecnie brak nowych wiadomości. Skorzystaj z przycisków u dołu ekranu aby przejść do widoku mieszanego lub listy kanałów.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "recentDmConversationsEmptyPlaceholder": "Brak wiadomości w archiwum! Może warto rozpocząć dyskusję?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "channelsEmptyPlaceholder": "Nie śledzisz żadnego z kanałów.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "initialAnchorSettingDescription": "Możesz wybrać czy bardziej odpowiada Ci odczyt nieprzeczytanych lub najnowszych wiadomości.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "Pierwsza nieprzeczytana wiadomość", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingTitle": "Pokaż wiadomości w porządku", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "discardDraftForOutboxConfirmationDialogMessage": "Przywracając wiadomość, która nie została wysłana, wyczyścisz zawartość kreatora nowej.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "initialAnchorSettingFirstUnreadConversations": "Pierwsza nieprzeczytana wiadomość w pojedynczej dyskusji, wszędzie indziej najnowsza wiadomość", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "Najnowsza wiadomość", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 13912d380a..a9707ff7a9 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -75,10 +75,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Ответить с цитированием", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "Отметить сообщение", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." @@ -1132,5 +1128,29 @@ "inboxEmptyPlaceholder": "Нет непрочитанных входящих сообщений. Используйте кнопки ниже для просмотра объединенной ленты или списка каналов.", "@inboxEmptyPlaceholder": { "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "initialAnchorSettingNewestAlways": "Самое новое сообщение", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingTitle": "Где открывать ленту сообщений", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "discardDraftForOutboxConfirmationDialogMessage": "При восстановлении неотправленного сообщения содержимое поля редактирования очищается.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "initialAnchorSettingFirstUnreadAlways": "Первое непрочитанное сообщение", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "Можно открывать ленту сообщений на первом непрочитанном сообщении или на самом новом.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "Первое непрочитанное сообщение в личных беседах, самое новое в остальных", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." } } diff --git a/assets/l10n/app_sk.arb b/assets/l10n/app_sk.arb index ba700eb33c..4d6279d7b1 100644 --- a/assets/l10n/app_sk.arb +++ b/assets/l10n/app_sk.arb @@ -119,10 +119,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Citovať a odpovedať", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "Ohviezdičkovať správu", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." diff --git a/assets/l10n/app_sl.arb b/assets/l10n/app_sl.arb index f539084ab7..cfa3cc89c5 100644 --- a/assets/l10n/app_sl.arb +++ b/assets/l10n/app_sl.arb @@ -125,10 +125,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "Citiraj in odgovori", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "Označi sporočilo z zvezdico", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb index 0e6e5c452e..0f7291df60 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -173,10 +173,6 @@ "@errorResolveTopicFailedTitle": { "description": "Error title when marking a topic as resolved failed." }, - "actionSheetOptionQuoteAndReply": "Цитата і відповідь", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "signInWithFoo": "Увійти з {method}", "@signInWithFoo": { "description": "Button to use {method} to sign in to the app.", diff --git a/assets/l10n/app_zh_Hans_CN.arb b/assets/l10n/app_zh_Hans_CN.arb index ce32a1a36a..5e5c347f44 100644 --- a/assets/l10n/app_zh_Hans_CN.arb +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -735,10 +735,6 @@ "@actionSheetOptionHideMutedMessage": { "description": "Label for hide muted message again button on action sheet." }, - "actionSheetOptionQuoteAndReply": "引用消息并回复", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "添加星标消息标记", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb index de5f3b4cac..decbe1c885 100644 --- a/assets/l10n/app_zh_Hant_TW.arb +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -117,10 +117,6 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionQuoteAndReply": "引用並回覆", - "@actionSheetOptionQuoteAndReply": { - "description": "Label for Quote and reply button on action sheet." - }, "actionSheetOptionStarMessage": "標註為重要訊息", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 084eedfbbc..cb6dc8a2ce 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -9,155 +9,161 @@ class ZulipLocalizationsIt extends ZulipLocalizations { ZulipLocalizationsIt([String locale = 'it']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Su Zulip'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'Versione app'; @override - String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + String get aboutPageOpenSourceLicenses => 'Licenze open-source'; @override - String get aboutPageTapToView => 'Tap to view'; + String get aboutPageTapToView => 'Tap per visualizzare'; @override - String get chooseAccountPageTitle => 'Choose account'; + String get chooseAccountPageTitle => 'Scegli account'; @override - String get settingsPageTitle => 'Settings'; + String get settingsPageTitle => 'Impostazioni'; @override - String get switchAccountButton => 'Switch account'; + String get switchAccountButton => 'Cambia account'; @override String tryAnotherAccountMessage(Object url) { - return 'Your account at $url is taking a while to load.'; + return 'Il caricamento dell\'account su $url sta richiedendo un po\' di tempo.'; } @override - String get tryAnotherAccountButton => 'Try another account'; + String get tryAnotherAccountButton => 'Prova un altro account'; @override - String get chooseAccountPageLogOutButton => 'Log out'; + String get chooseAccountPageLogOutButton => 'Esci'; @override - String get logOutConfirmationDialogTitle => 'Log out?'; + String get logOutConfirmationDialogTitle => 'Disconnettersi?'; @override String get logOutConfirmationDialogMessage => - 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + 'Per utilizzare questo account in futuro, bisognerà reinserire l\'URL della propria organizzazione e le informazioni del proprio account.'; @override - String get logOutConfirmationDialogConfirmButton => 'Log out'; + String get logOutConfirmationDialogConfirmButton => 'Esci'; @override - String get chooseAccountButtonAddAnAccount => 'Add an account'; + String get chooseAccountButtonAddAnAccount => 'Aggiungi un account'; @override - String get profileButtonSendDirectMessage => 'Send direct message'; + String get profileButtonSendDirectMessage => 'Invia un messaggio diretto'; @override - String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + String get errorCouldNotShowUserProfile => + 'Impossibile mostrare il profilo utente.'; @override - String get permissionsNeededTitle => 'Permissions needed'; + String get permissionsNeededTitle => 'Permessi necessari'; @override - String get permissionsNeededOpenSettings => 'Open settings'; + String get permissionsNeededOpenSettings => 'Apri le impostazioni'; @override String get permissionsDeniedCameraAccess => - 'To upload an image, please grant Zulip additional permissions in Settings.'; + 'Per caricare un\'immagine, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.'; @override String get permissionsDeniedReadExternalStorage => - 'To upload files, please grant Zulip additional permissions in Settings.'; + 'Per caricare file, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.'; @override - String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + String get actionSheetOptionMarkChannelAsRead => 'Segna il canale come letto'; @override - String get actionSheetOptionListOfTopics => 'List of topics'; + String get actionSheetOptionListOfTopics => 'Elenco degli argomenti'; @override - String get actionSheetOptionMuteTopic => 'Mute topic'; + String get actionSheetOptionMuteTopic => 'Silenzia argomento'; @override - String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + String get actionSheetOptionUnmuteTopic => 'Riattiva argomento'; @override - String get actionSheetOptionFollowTopic => 'Follow topic'; + String get actionSheetOptionFollowTopic => 'Segui argomento'; @override - String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + String get actionSheetOptionUnfollowTopic => 'Non seguire più l\'argomento'; @override - String get actionSheetOptionResolveTopic => 'Mark as resolved'; + String get actionSheetOptionResolveTopic => 'Segna come risolto'; @override - String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + String get actionSheetOptionUnresolveTopic => 'Segna come irrisolto'; @override - String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + String get errorResolveTopicFailedTitle => + 'Impossibile contrassegnare l\'argomento come risolto'; @override String get errorUnresolveTopicFailedTitle => - 'Failed to mark topic as unresolved'; + 'Impossibile contrassegnare l\'argomento come irrisolto'; @override - String get actionSheetOptionCopyMessageText => 'Copy message text'; + String get actionSheetOptionCopyMessageText => 'Copia il testo del messaggio'; @override - String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + String get actionSheetOptionCopyMessageLink => + 'Copia il collegamento al messaggio'; @override - String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + String get actionSheetOptionMarkAsUnread => 'Segna come non letto da qui'; @override - String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + String get actionSheetOptionHideMutedMessage => + 'Nascondi nuovamente il messaggio disattivato'; @override - String get actionSheetOptionShare => 'Share'; + String get actionSheetOptionShare => 'Condividi'; @override String get actionSheetOptionQuoteMessage => 'Quote message'; @override - String get actionSheetOptionStarMessage => 'Star message'; + String get actionSheetOptionStarMessage => 'Metti una stella al messaggio'; @override - String get actionSheetOptionUnstarMessage => 'Unstar message'; + String get actionSheetOptionUnstarMessage => 'Togli la stella dal messaggio'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'Modifica messaggio'; @override - String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + String get actionSheetOptionMarkTopicAsRead => + 'Segna l\'argomento come letto'; @override - String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + String get errorWebAuthOperationalErrorTitle => 'Qualcosa è andato storto'; @override - String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + String get errorWebAuthOperationalError => + 'Si è verificato un errore imprevisto.'; @override - String get errorAccountLoggedInTitle => 'Account already logged in'; + String get errorAccountLoggedInTitle => 'Account già registrato'; @override String errorAccountLoggedIn(String email, String server) { - return 'The account $email at $server is already in your list of accounts.'; + return 'L\'account $email su $server è già presente nell\'elenco account.'; } @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source.'; + 'Impossibile recuperare l\'origine del messaggio.'; @override - String get errorCopyingFailed => 'Copying failed'; + String get errorCopyingFailed => 'Copia non riuscita'; @override String errorFailedToUploadFileTitle(String filename) { - return 'Failed to upload file: $filename'; + return 'Impossibile caricare il file: $filename'; } @override @@ -192,49 +198,49 @@ class ZulipLocalizationsIt extends ZulipLocalizations { } @override - String get errorLoginInvalidInputTitle => 'Invalid input'; + String get errorLoginInvalidInputTitle => 'Ingresso non valido'; @override - String get errorLoginFailedTitle => 'Login failed'; + String get errorLoginFailedTitle => 'Accesso non riuscito'; @override - String get errorMessageNotSent => 'Message not sent'; + String get errorMessageNotSent => 'Messaggio non inviato'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Messaggio non salvato'; @override String errorLoginCouldNotConnect(String url) { - return 'Failed to connect to server:\n$url'; + return 'Impossibile connettersi al server:\n$url'; } @override - String get errorCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Impossibile connettersi'; @override String get errorMessageDoesNotSeemToExist => - 'That message does not seem to exist.'; + 'Quel messaggio sembra non esistere.'; @override - String get errorQuotationFailed => 'Quotation failed'; + String get errorQuotationFailed => 'Citazione non riuscita'; @override String errorServerMessage(String message) { - return 'The server said:\n\n$message'; + return 'Il server ha detto:\n\n$message'; } @override String get errorConnectingToServerShort => - 'Error connecting to Zulip. Retrying…'; + 'Errore di connessione a Zulip. Nuovo tentativo…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { - return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + return 'Errore durante la connessione a Zulip su $serverUrl. Verrà effettuato un nuovo tentativo:\n\n$error'; } @override String get errorHandlingEventTitle => - 'Error handling a Zulip event. Retrying connection…'; + 'Errore nella gestione di un evento Zulip. Nuovo tentativo di connessione…'; @override String errorHandlingEventDetails( diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 3943e5c01d..e046358d11 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -334,7 +334,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get discardDraftForOutboxConfirmationDialogMessage => - 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + 'Przywracając wiadomość, która nie została wysłana, wyczyścisz zawartość kreatora nowej.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; @@ -352,7 +352,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Wpisz wiadomość'; @override - String get newDmSheetComposeButtonLabel => 'Compose'; + String get newDmSheetComposeButtonLabel => 'Utwórz'; @override String get newDmSheetScreenTitle => 'Nowa DM'; @@ -649,7 +649,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get inboxEmptyPlaceholder => - 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + 'Obecnie brak nowych wiadomości. Skorzystaj z przycisków u dołu ekranu aby przejść do widoku mieszanego lub listy kanałów.'; @override String get recentDmConversationsPageTitle => 'Wiadomości bezpośrednie'; @@ -659,7 +659,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get recentDmConversationsEmptyPlaceholder => - 'You have no direct messages yet! Why not start the conversation?'; + 'Brak wiadomości w archiwum! Może warto rozpocząć dyskusję?'; @override String get combinedFeedPageTitle => 'Mieszany widok'; @@ -674,8 +674,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get channelsPageTitle => 'Kanały'; @override - String get channelsEmptyPlaceholder => - 'You are not subscribed to any channels yet.'; + String get channelsEmptyPlaceholder => 'Nie śledzisz żadnego z kanałów.'; @override String get mainMenuMyProfile => 'Mój profil'; @@ -785,21 +784,22 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get pollWidgetOptionsMissing => 'Ta sonda nie ma opcji do wyboru.'; @override - String get initialAnchorSettingTitle => 'Open message feeds at'; + String get initialAnchorSettingTitle => 'Pokaż wiadomości w porządku'; @override String get initialAnchorSettingDescription => - 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + 'Możesz wybrać czy bardziej odpowiada Ci odczyt nieprzeczytanych lub najnowszych wiadomości.'; @override - String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + String get initialAnchorSettingFirstUnreadAlways => + 'Pierwsza nieprzeczytana wiadomość'; @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'Pierwsza nieprzeczytana wiadomość w pojedynczej dyskusji, wszędzie indziej najnowsza wiadomość'; @override - String get initialAnchorSettingNewestAlways => 'Newest message'; + String get initialAnchorSettingNewestAlways => 'Najnowsza wiadomość'; @override String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 13a6729b9b..57b33f03b1 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -335,7 +335,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get discardDraftForOutboxConfirmationDialogMessage => - 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + 'При восстановлении неотправленного сообщения содержимое поля редактирования очищается.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; @@ -788,21 +788,22 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get pollWidgetOptionsMissing => 'В опросе пока нет вариантов ответа.'; @override - String get initialAnchorSettingTitle => 'Open message feeds at'; + String get initialAnchorSettingTitle => 'Где открывать ленту сообщений'; @override String get initialAnchorSettingDescription => - 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + 'Можно открывать ленту сообщений на первом непрочитанном сообщении или на самом новом.'; @override - String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + String get initialAnchorSettingFirstUnreadAlways => + 'Первое непрочитанное сообщение'; @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'Первое непрочитанное сообщение в личных беседах, самое новое в остальных'; @override - String get initialAnchorSettingNewestAlways => 'Newest message'; + String get initialAnchorSettingNewestAlways => 'Самое новое сообщение'; @override String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; From f8677acccecb8490cea3893851eeb31c5855db2a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 14 Jun 2025 00:14:43 -0700 Subject: [PATCH 199/290] version: Sync version and changelog from v0.0.33 release --- docs/changelog.md | 31 +++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 53c33e91d5..2f514a89f9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,37 @@ ## Unreleased +## 0.0.33 (2025-06-13) + +This is a preview beta, including some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +This app is nearing ready to replace the legacy Zulip mobile app, +planned for next week. + +In addition to all the features in the last beta: +* Messages are automatically marked read as you scroll through + a conversation. (#81) +* More translations. + + +### Highlights for developers + +* User-visible changes not described above: + * "Quote message" button label rather than "Quote and reply" + (PR #1575) + +* Resolved in main: PR #1575, #81 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + ## 0.0.32 (2025-06-12) This is a preview beta, including some experimental changes diff --git a/pubspec.yaml b/pubspec.yaml index 54ddc70092..ef3c5bc5ec 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.32+32 +version: 0.0.33+33 environment: # We use a recent version of Flutter from its main channel, and From 67af2ed1469e944119a1f78ad06b964c0461d1d4 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 13 Jun 2025 23:35:08 +0530 Subject: [PATCH 200/290] logo: Switch app icons to non-"beta" versions And remove the beta icon assets. Fixes: #1537 --- .../mipmap-hdpi/ic_launcher_background.webp | Bin 142 -> 128 bytes .../mipmap-hdpi/ic_launcher_monochrome.webp | Bin 488 -> 290 bytes .../mipmap-mdpi/ic_launcher_background.webp | Bin 120 -> 106 bytes .../mipmap-mdpi/ic_launcher_monochrome.webp | Bin 298 -> 200 bytes .../mipmap-xhdpi/ic_launcher_background.webp | Bin 170 -> 156 bytes .../mipmap-xhdpi/ic_launcher_monochrome.webp | Bin 610 -> 374 bytes .../mipmap-xxhdpi/ic_launcher_background.webp | Bin 242 -> 236 bytes .../mipmap-xxhdpi/ic_launcher_monochrome.webp | Bin 896 -> 514 bytes .../ic_launcher_background.webp | Bin 304 -> 290 bytes .../ic_launcher_monochrome.webp | Bin 1176 -> 688 bytes assets/app-icons/zulip-beta-combined.svg | 20 ------------------ .../zulip-white-z-beta-on-transparent.svg | 4 ---- .../AppIcon.appiconset/Icon-1024x1024@1x.png | Bin 25174 -> 17206 bytes .../AppIcon.appiconset/Icon-20x20@2x.png | Bin 928 -> 673 bytes .../AppIcon.appiconset/Icon-20x20@3x.png | Bin 1438 -> 882 bytes .../AppIcon.appiconset/Icon-29x29@2x.png | Bin 1342 -> 897 bytes .../AppIcon.appiconset/Icon-29x29@3x.png | Bin 2070 -> 1339 bytes .../AppIcon.appiconset/Icon-40x40@2x.png | Bin 1879 -> 1209 bytes .../AppIcon.appiconset/Icon-40x40@3x.png | Bin 2852 -> 1820 bytes .../AppIcon.appiconset/Icon-60x60@2x.png | Bin 2852 -> 1820 bytes .../AppIcon.appiconset/Icon-60x60@3x.png | Bin 4263 -> 2776 bytes .../AppIcon.appiconset/Icon-76x76@2x.png | Bin 3536 -> 2297 bytes .../AppIcon.appiconset/Icon-83.5x83.5@2x.png | Bin 3914 -> 2540 bytes tools/generate-logos | 8 +++---- 24 files changed, 3 insertions(+), 29 deletions(-) delete mode 100644 assets/app-icons/zulip-beta-combined.svg delete mode 100644 assets/app-icons/zulip-white-z-beta-on-transparent.svg diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp index d9cb74391c332007c109bd9068e30591f33ae0fc..29ac2c64dff8ff7f5b882d68be070c0db26c404d 100644 GIT binary patch literal 128 zcmV-`0Du2dNk&F^00012MM6+kP&iC%0000lp+G1Axs2$4>>{H70R?RvIpt5{p_aj~ zjuQx^u$i9!P~^6eGPsoU@(0$z{*-J`kS&HARAz_akujjo<>NtTw3Y+Qq4@GVpDT7NnFMnVi>`%!C1=(V_L1lIr9vK5#o+)y9 w)u-hZiydN6%QJ;iFr_p{%8G?)4y(TM^>&oG6>R+fxijqj+;MEouV)GX08L6fDgXcg diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp index d02707d8d7fbe97775b16ac56070d048cc738544..491a79190f53830d2f79fb3e51f31434bc56a194 100644 GIT binary patch literal 290 zcmV+-0p0#mNk&E*0RRA3MM6+kP&iBt0RR9mN5Byfl|XVE$&obw|1@XTW@ld4#Cns! zAOfld00g7iwr$2WWg{`U+S@N7Mf3flb8nTA@e{~l;wnNOU{S0fvYdG!nT2IWCH z@=$gI_2q>Wcc`zgFIT8S&9jtwx-Agr53&dZq)US;a*QnGE-P9EqR}}l+Ihwb(Dg?# z5D;}P1un7#4Ga$iuJe4ug4$6PfPhe6u2A0KjDS|Zj(MiBk@X-e5Rfhnk_B#P^RC-2 z0{U^r3ZS&7F%S@SE-%o*sT3mAAFpYuV61uZPP+g*(1mj+Bnt%M16f%3!j)K3H4PIp emJBc!2y!~ROi&gGI_d?2j%I;`2+?V8XaWGoU*Ycn diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp index 68435f6ce810ea3e935e93edf2d563a66ca26859..509773e6586df20dab772269714ee5978ec607e7 100644 GIT binary patch literal 106 zcmV-w0G0nzNk&Fu00012MM6+kP&iCh0000lYrq-+xs2$4>>{H70R?T_FyT*PP>0|@ z2?SEuOwWHPa@$B5#y#@BKn|8rV+s^gKx~ON3`1OPaXC30Xl<;~xHjf1s8?Ih57dTX Mc(M2P_+kH10sN6F^#A|> literal 120 zcmV-;0EholNk&F+00012MM6+kP&iCu0000lYrq-+2_Q-E|9ERe|5ITBB!vIx)rkJ5 z0u;1u!-PMHK^=nsBoIhpGd=&I$ZaEK828Bg0y$VhjVVw}0kI|8Fbr|E#pUF1ptZ3^ a_S>(T3-VA{v z-~a$XFqv&z8K3L3ZS%5i*K6B#`Lb=>wv7qMvpWo3HfK`@ZX}1hQ9$UOv3k=K*`E#M=NAjeU+4u^rP@Et0EfWkX$8Y zSv+gsKtbl3WyyccTUMswVXOhI#Lmaex8l>%3EF7-o}yc>{H70R?RvIpt5{p&o?w z9Rh(AHq-MT3fs1Ax_g0acO38zx~fH61p(Wt#kvNhvCS3MN?#j3)B`@s&+3;h{a@U) z^L`fcAvbv=SK&iF;G_Jke(BQx#ZBMqXL+8RypgNqNO~ zUPD3KMo#&Yc&G4pVcp2`oFm8=KU-H0Jd#db^rhX diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp index d71f15fe5a695273f12ba8946b305f1bb994b681..b484b79c87f8735ed6a724e72dc80df057416207 100644 GIT binary patch literal 374 zcmV-+0g3)nNk&F)0RRA3MM6+kP&iCt0RR9mU%(d-h1<4~B&qUGn#O+DQ*9q2pkUi} z?73&#wvD7@qcYmf+Z%`EW!qab)1qzL_=kG}g8!#k$W`@wLM*vHQYuJr)H>%ns=zwxgu*qVx)i(hHM+KLeoy@?9ers4(>-SG?dR*Z-a*ar(!QF}wxEus|$ z!NRmH6E}(wO;QgQW@XD0tqT>`sFhr>Fke?l1Xo3I#o!f zj;DJZ)=jF6f-M}ET_zFdos#O(b^vniGIN56E)ypR=pqjAO|6R+$(**JT0KhVmvbJs8wr$(S&$eybwr$(CZR2hN`rH5bM5|DJa0MKp)dMZZ|9M>y zDJLa!dqGzWi+{64b1{*b-!H6MIu{V)wG1qQY^~QNps7W30oJ=VDD(5tDb}bQ%_XYK z!3fJuAGu{VYJ<=7);>f8UP*9};%>Gp_u|4)v+ zNs@367?iXmE?>6{8Jwyl;nKr~VK_YZdcat>N)l$ z^37b{x>3fVtm|u@4O71A+n87-gX~`Yg?l|Gp1YAoZ#U{_n!rvWq1)XJ*_#SyFJoX9 z{)b@@de=cy&p-E<(Fkti(%$hXW-krr&?tp(Js)h@N%K!9h(;YdZ*OlD_krreJT5}M zE!Xqh-P(h-QAg7Rc5Y*mtr$lD&Qj1FLTf1C`prKAtJ(Lf$FS>Y1JTIh&ED~-bwhAy wcY{B-IDu}scV??GQN6D6*?^fqp9o*qb-mueuNhG{DCHC<(A|9aRr?M?07HN?#Q*>R diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp index 061cc27b1b60ed3e16c036269f0a902be6c1c519..a5d6517a8606b823b7a9ef821ae17f708c9396ec 100644 GIT binary patch literal 236 zcmVj0TWDs4OA#;G%u{xh|F8s8 zScegbVkVZuN-X!K3a7A+r@Wk4K5wSH<#5M78-OXSL*B!_wA;+-|6vKHunr><#T;JN zzS&y|{%hY9MD3f7eKvrXSx2{-)9-!>{%haBp7u=_U>*3ceFJ;iH~&W%|Nmc8vj1Q6 mAC`1%*1^l~(7xsHf7sjiL)MWgX9IZoj$5S6>4H*NhX4S?a(e}6>uKYg@N+qMnE|9?-< z5Ch)-B(i)*DH$LLm?rcuVN@#oDoh}d!e)B@Ly_CIO>zoMkI=vSPm<(+?Ohx!aAPva$@}lWpA7T9e se@)5$f6aeb(y>_wFTX?kmc##HZ{H7DN2Z(&;N?4Rkus+XN?{!W032O#WB>pF diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp index 7a4c431361fcf77a93ade1f81fd5f673b18f111d..25f3ead329d5e764d415ab61920352c69cb340f4 100644 GIT binary patch literal 514 zcmV+d0{#6`Nk&Hc0RRA3MM6+kP&iEO0RR9mkH8}k5?HeBwr%#m>XRmU0s_oeCP?p? z{`VgW004%hvyHcHf3uOSX4_V@{nfT@+qP}bI&#}al4uz2nYnM(^Mc+^p8WSX6ka-P zV#16xcn$Q70idur;187_xU`9lag6k&fTCj_E;&Xo;YMb1W+t8=f~irQ6%m!1kR=SH zreF!jsVQH=Q)+USu$3C$C3K`ld?!Z08S2 zWnKd*{jTv%5QYfVdqvtANqhJfE&s?L_dOK1j{~2qdk^Ft>YhgLWgU|0y{r|yy_eOG z+O^@oxXNIPm$%g;e70wpW?#IB+UIQL&Uj%|yqG5D z!n&YTyhQBGg|$wgc&WRd3u_Xqc$s;d8=Ov#T-3uA@2g+X`8FQN;uQhR-Q7g~70le- zO@LK1vz0|RI(u1yHfJwO(fP6Vp|^mZ=Q%R@OZ7e>i&6v!-xC)9hL~5t517!XflH4+ zFu~CcnH+gP6CSb2>0faUhMD^f510iJxSexUovBG*!a-`vmhhbWOlb*659)m!3fsBQ z)y5a_g)cXTFY%;z@<*<931?`i-5sEuhQ_%^^m1s54q;|!o*cr_(9|8` z*U%&#LdVc39YV&?U|2c=!J&o60lduEp?uk$EwqCR0zY>48*hdWQ?!#knaA-!W(b#w z7&$N1{)|3U59ACAf5LYJIfJk)Z)t^@Q^>!YN}+NJKkha6{BjZtR$a5oN&L8198%0# z1_aZ{Sf%<_=wL+wa-rTQ}@7clf@xb&hJ|izR2YGk27FKwJ6Q+un`*L`~wJUT=+x_1xM2luo4Hv@fW z@SPu^%!kGr98v4gWE{fe&@>;y;m|Z5!q(8FAHv|!XdOby(BRlP0{Nju*)r9JgvQj%DTY1lTwdZW`Vtx_cH(MHAXfJARtLJw&d5o<>@CxbsO&_ zB!Y5yje)wQCYi!MBKL z87<;-i*sxQes^6rrX*3&O)+b})IpY->IlvX`KZ(xU&2?YXzvCVh{82Vs*>8zI2bvf3&pNXx zzh>LMJGwFoASq-mUlv^fsqm7K4+8@LgeRft diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp index 98157df21623c81d4e8902e1a8dc1232b9e9a9e7..cbd20f3a9d7dcca92b8172677f337de800ad71d5 100644 GIT binary patch literal 688 zcmV;h0#E%?Nk&Gf0ssJ4MM6+kP&iDS0ssInzrZgLmB^B9w{4`}|EA9bi4zFmvyIMe zqeBD~000b0=eKR!wr$(Coow5-ZQI|BX4}}AfWG+u;{SUiBf|RsBP|3XzUI9GJ9@V) z2w4p{x&rof&kH=iM>(^%M(FgE%tQ|x9Nby5!gI zqi=Ct=1=i!dgstL+b%PHr>2*#paaj;y3CY40+g3-!0#@vU1lae$5O8n2L0v)`yH&z z0{qWVUb+D@V+}=D8C=}VSn5^6pqbGRx2p`JX=aA<(hZnV1JzZAZ**qnu~!Ly&q(Vs zqqaT2Q(4k8{&;qou{oaKE%hq?CvWt-%%t7U;p>u4Hzd2v47>w}=6hK*jyqVHIs2XC z*R?{6n$yQFIi#{Ve=QkYgLD4a6-W9AEK;o)76-FvsS8fP*{o767&XtcYN_kZ-p?%4 ztJgDV)@4!uKPh5`t^$OD+0}z8A|-&W6WI7o5#0O`Sp@dBj3hfqGr;avp_3Cl`^(G? zy_W#|Voy!pM5sEmoBI$RU=kxaF;Y&9XAnXY6CZ^1#H0nGJTc)xNJ@--5d0Iv5(Lr2 z+EECuAut^qAqZ@bdt{_6*X1#3j WX;A>ut|KC>|38wx`2XVn`y&IoDodmQ literal 1176 zcmV;J1ZVqFNk&GH1ONb6MM6+kP&iD31ONapzrZgLmH3t<$&w_=w(S3Z8Xw$DJ-|&> z>fRxc5s~iR9G3_Q005Sa)SYeHwr$(CZQHhO+qP}KZTqK8K)?F`b@=cJ(r1193<)DF z11X(;0O#~uAH@{k&mWec0m-lH z2nYFXGQPqeW?k;bn9me9sNN2SJj2)ZW|C2tm^P^3&k-v~yWmWKK|}C1sN&B7U4P+cke4_|Lv;&N%yi(&;i&H#G$2aL;QZU1qla=^SE|T{l>}%tD|b@eMt9?4jx^ z|B{w`_UL;qjlu6K!_l4G@eMsMG{fpDBQlrdvqv9Eprv)0(K$(S$2Vj@x%1wjGUM_v zNj^7dlu_ar!!9#fAClz$Gh{zLk?%4y@jI8F8#Kx&p5W;+^M%ai{b$I!Lo{7wFd4wU zt{$d?sH+T9efIA^V|7MoDu<6QISSjr;r*v%*xlv$(G@4<9dLaAsTfV)Gx+F&a|X}g z{ik3;p)>mEa{pfyFiyE#@C@fl3-WGL94A6mKyD+ab1+WA-vjZIK!{>F*?G548 zA@w9@_Y&3ORObA~*G3X@0->}teZUM&uMECFQviP~jArJ@bYZ_IA$DQ1CZTj;aweg6 zVL~S%ZDA}Y!E<46P;YRCe>C}V}Bu7lFc=+6RC18WP zF`0dix>J0B)}&uvi>QE7V9-nAd+7Yj-~&dt>|9A?&rFgqE`YU>c!6+-dPePbH-{^H zL%D+w`od}f>?B@yH!`M=B;Pyqk|?3IO9g&+H+j}?Z{JDmy}iA$!qU6a6TcEDjAiI% z2_$j`cyG|vaFZD7QFn=WUlU=4z@9|>4G~R(M^Exm2qsBr+>o5aZL>>$uvP(#t`~`g zz9Ekid5sJ_5nTaDkDs3Tm-wp=!H8#$c1>e>DN;!&bX!UsRp8K(c$6c@(q2ko;vI}` zAwYt&0N{?gUHX;8hp+1%G+*eKXNl(S#-2n%0le<+hQZDM#8Ws&;&JRE>K7uPL<^tK z&rhEdXI1EypM>cV?+v>83Z)swR|dvOB;5!Mug0X5fp-$IH$pjy-uGSKfVi##2G>AB z??zMsfyUCiI+GAup6~DP;*=WqKZ$$TM{X?cnqvMkUMCKF|1F8+wV4D)eI?PCq%!I! zj00FuiQYYIKqCGC{#RmRZ7d*7dmnTGrNhIq|vV qKppW09m8e7`*WW&ngO(8IKtR#>N3dpF#_SDkuL7l|F8c4LkIxWok2GM diff --git a/assets/app-icons/zulip-beta-combined.svg b/assets/app-icons/zulip-beta-combined.svg deleted file mode 100644 index ea6d487bf6..0000000000 --- a/assets/app-icons/zulip-beta-combined.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/assets/app-icons/zulip-white-z-beta-on-transparent.svg b/assets/app-icons/zulip-white-z-beta-on-transparent.svg deleted file mode 100644 index ed8a592ef1..0000000000 --- a/assets/app-icons/zulip-white-z-beta-on-transparent.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024@1x.png index ad38ed7d1a5cb31a0001487258ec146e9f13d2b9..4d6b2fe976e575f653c3505a79b9311e4a4cbaa6 100644 GIT binary patch literal 17206 zcmcJ12V7Lww)X)9R1hSffYhiFl_sE68ODl6=^!8i3L;3A-kDLOBHbXM(ne7c5fG$T z6&RGR^sX}^Ff@lcv^n28Ci!0OySXp-z3+VqzX|NKXYJMZ+H3z$ZtLr6Z`!zRBZ45C zPM$bwh#>3W*LBExR`}01LU}v~^GfDzcti|4vo%gfCaYsywl~v?oh38tOje zbNYNe0(>kzU3k`YtXDVx@I&56(rmxh-7_&iDcN5NnQfe2W1iY+|J61q**>LZaWQeo zXu>~47(wvj0fwBwRIfG*f)FEi!>=)X?C`1h2tM$~1>p1D3E3o85UJ-SCWWv8d`Rvn z*zOH~kz8K<$F$HtJ;pR}@#tUEbToJx1&{N?x4`GSfA?Fzaguc+8NTEoA`PGa!07)2 z(SPFL@m4$9dXT=%?7IN%VeK10Ifc3ZrhMNy{X3$6t?561>u)q%u-ix*l+auIU#a@9 zS@|2goccq_7DUX_|5N(yk4}q${u~7V$yWcw)!&HvCl3A_ME~@yzgG5%tp7z78ip%+ zL#&f5|2`o8eSi9I5dCWrfAgn|EgAgaDlSHU@aW9K`Y(h1&o21)DwM5n=Y!br`4_SA z?~D2f7{nj{Uy9;?^r!zNqTiMApHjo0v~DPED22r%`EQl>Pa6K$M1RxDKgG+RmcTSj zJR_p9eOH!5EV=d))rP8|;B(Y@-`-9MzUo^?k4A`#XS2mb+hwN7*y>mxtWw-68(k|+ z>9M1KnZQRxNhrn5$$sm6^T44wtiG7?bh07tq2LR zzSDk7RhIb&FKP1gYVt3(M3<#Tm^k$%@i^R~e9F0-p@cN$4s+0Ny4beJ+SKXTKJ($_ z)*Zqh+^6MAIOv+h&ntf-nZqH4@=0DA(RZ|6u+R(GuHTX(Y~&+TJ#-5cfH z=j0SF`N5R<&V@SfS2ka>=%a7j4T#Ho5dEKG>+0UG)E??P7JWUC*j^uMAs)%)7RLEC zJ&LuxdU4&uZH4`kx`XmOgM(RGt$V^kgNwr8X9)L$;OeeRO96=SFRVziYvVUn?<&6) z*AL@wg7Ml9D!nJlyjN!)GPsVOm^`h`Gi`oba z8*)}|p_aGgr;aV01b<0yj!#>hD=w@y@!jALA=dlPEHUa$hQ;(AH?x#)WJlC>v!rwf zb5z|^pY_bL8MonuwaS$${g0INzWU|iY;hH_S<$_ML36J?**37owx!dkCbz7vXwdX@ z&u(vlPkP0*L>>D@(tl38z3$actHACausbiB5tPC!FsmCcgYnvpxqpiD@_&nAGWx>?t~ zcTvMJrvv-f*eqQWH_&sYy|b=IwQ4MV^E9+422B=U4jO9-qo)~-APyB5j!<4^)Rx!M zyTKxoZf6k5`)q}gIpWq4V!#i+j618gM_bTsK+T)yT_P)TS+qPjX{FHSgd0s1nd~sC z87lAjfR8c6b|pnGMn0p)B22t01Roz3$}4tuZsG3Gk|k|-%umuttE*eLGA`tC89~zN zSPdty++cw{O*G~wcBI!aT9z_l8)jAIkMI$^Pq=^4%{YP&>A#6zIX$ow=ldcUMF{#w zgC;_4hO&%Ch>g3E)?Fewf~kSp3PC4;kL1TiQq8#1?n{S(AU_bi@THZpnp?|}W;lY3 z9^7$@EI4R#S}0bX^WqT}V!U*i>dL5%)2uAfR3XF*q(yyKR zF2pjv{n)I1-A5lG6)f~C>h`v1Z+kr+*ys9LU($h%9Xac;c88vkCc4D?G8^Y%mb=6O zEEi=SyRQzhvYkTJ-by|Gw3$U;tol|>dh}jg(8->zRP~Ks5L>N#>!$eHT-G?BwfTtD zz4y^QEWHP5{ei!Z%sDv9#!cdzzAxUVcUp|KGyzzm=k7Q-%SAe*d2BUjNBDz80f$Xh z7Og?rQv!jyx?-%P`ofgW(}Nn`>qiYYBKv#peWnX?_N}t*Gn1OOb}qM~ZElSeKpgUn zcz2VdPQd)-n;n#h>y1XFB^Z7xTUV!0_JoI}RhH-ewzAVd!=h2FNYh*b=St@`yi)MD zn74b8{RtLZ93CVqhX;$T^0pfH=|!kGC#bLUN_cQ)Q_~Dng%Ql^P}#<_+cl_IrlDPp zWMRfa{Mv2X(Crf5{v%TQ;mam!YX+!C&;TK+4Jr*VBuFN@NmGeP>waMQ%>uuzFO@)y z?{Tc|u1k19I_Y>pTa3GxzH;w0OMWLkP*A1Cy^B@nJUhbS^!A==>xM0eY~lFU+{xoW z(^MZh|1zq#U^Ev&S_A4PzB+R0kdW7;xrG`FpOa#&lDxNc!tWe0x<#ZJA?Nj!pWS(`KD8AgkU`7+w*Exfb_{v=FtFzz-efmDiNjm5^x$*8l z-zB?BLmua^Z2f$gI}&#O$a{sCY3mUG#JpT>g~QhckY{~0xw*`DV49gTA`)#{Vo_N9A-e}9olVE zew|3-+laK=JIE@FgLV);(CtlI(CtP;goKMFAB(=tkGJpc|5c`4kA=7_gtB=>MRuJ^ zM)sb7KTp5x=WbQlwHwjb0;;b*1SlgcUK_hEI^ePLg=9#M)=fr;f-b7+{qOM=FA#ii zr^p?37DV1d=OzbyOF}{!Nw%%{oWYN0hy5h=1I`&yzv-m79}mPMw#8#pw;edR(VFe1 zI`;fw1=L&L9&La0)ry1mC4qxxWP~88sdJ&R>U=HURP9@e1Po_UEX+Wy4UiDPu< zk$|^N-0ig$-0kTENjO+B-}N%vH;^1bO?p*&+lac45PSvv5e>gbZ&3~efy6jN5{`y9JE|#lL(PGOhbIR0WQU{R|Cv}M;VJajK{NiaA{rv(wtY6v=#_0dvIFy%7fZk;#OOAY zSAjgV`8kSN-ga`->0~xZ+ch@&sM2R;j&1r-g%S4#e9P$e?F08yR9?An)+Xp`OJXTR z4(*sqaT;A0^HD7Ni7pcN1i|l^N=n3>dn%w&l4Bbzy5o`-f5RDwr^I5SK;pukNa)pD;xTZkjh@z>bXJ1 z)|#7LEzifiz;e`lUCT5?pCoTp>5x(P!wmw6(2LXogLtw5lI6RUm0j4nZX?3oxjLfx zD+g(K>Ve3sRLEVW;2t{rs46!Nk*xWOM9a$$)qw45=6baidXOYqE56ExCEBO+unZiA zJY3wk9lx666XU&(HH@Bc#dq z^ZYPzTzWqgO*)=+w(cXg8AlMW1htl$O}tNm@G%zRrRA?3WMkMWrn|b=cy%DdDQV7} zKId-_8XlmS^43g7!%RzU7UHh5ZwbRQ9YDc&h*rrca^glGVw+FiNJ1Uq+z3C{`InWR zMkVJ@VtbY2=i}@TCZ&pMf7BrBCZ@vA=${2&T<;>}aV%RwIDx-g~UP|yuGfQj6 zR@rdRD$DG%iRzqR4zUn@%t{q;nrY`C?sHKDMh#iU5D_S8M5N+7?K8 zJ6_njygX^Ro#3zoN}G|X68h3~+!|YkC*pNek&;R{JE$rR#aRR^;^N(QgdQi6pv%D? z8cHZDUF{JA216Wgi10b&I88_0Ia1GCQLx)kisi)U+wq3!A%W~)T0@}ZyDIG(F2DBL zN>b9uuBOV`vbp$2plBlNjl63wSUh@kDYs}#g`;XrY+ghTn!h?Wnc;~i3m_-H_6AaJ z#0{qpnnVaaP~V8R0GBomF}CuB>M%y-mG+6T&K~!#OT&lLt=WYAF4zSv8~UO#DXhqC z%|c0Bz#+B35GdcW=4#$DQc;7!dDz#8P^X`N^DASNeYnFH?BBZ07q`tm#Tr+z+nYyn zy8tq}*=d>5pe0j9f90WLP1}v=cu#7&cSd;hJa1Pc3PPkG3Rx{bK6dq6 zJmVv8LnRX)YeA~E_h0Q{jH9~=S5Enz-KT%qP8eODov{v?>KSQu2l}c&-QzP>vj1_8R^=7{rRx?@Oh%=L(d)1&9GM7F$wFAFmDvgHduI z>Y6*cB$Ql3+o}whb3Pi^h>m9YGB|ew|HXdb!PK;Tsr+iYFtMv>;Hmq^H?@O(P$_<+ zi4&BYOY_gY-098p=>X*IpEOQ~Es7aWl<0tt2$aD(YXAMHm0vbONgA*#GTL@QZZJ=q zJL^dl>t;T_D*t_z--7iNuR6lx=j6yRmC0?lrq4L%UVvlQU`UYDYD3&GF=y|gW3XlV z1LY&Wato>3sC95+xcD@p_*3spo;9eu9SUSQgQLz><`XIf-NBjNKCz>F3^`d#Er;+y zliI)8q#eoyx%~lt6N+k2U93*`>?|DEd3@6;IE<=Gch!tk8VxE-dGnA$YVT}Byb8A4 zP>Q>qgPaQwIjdK8J5&g1!0C6(cyt&alx1wsFVkmKM?TJcUIN{1a=*FWt3=U;Nx4_0 z8nSqc5K!`=x=(C_`k^e9T~XmjeB=g8wc7)X(lhQ#3D))`+?^E!p7;OELhM(|PjuF- zIw+oGIyiSe$=Bwy{($%X@%O6s$4f3AqlvzjoO{fJAbSRu9Fho;Y%*Q%vv}*I(6#|B zV{k|OR5rs>#@lEmAiGi;7ieDM=mK9M?Lm+)**RKM!3VuVn|;P|C=vv-1vQm*rPnm0 zky@u4mcy-R!g9WDQ0_%{Oyur}+4eB2sR5ITBHY8qx;NKqy?NpC0 zqnhSeqg$f~8_v|0DAlhxM_tQ?Dj}w9tgo)vAmfPFJ@!+S7vT+3TfrgnArkPNVWIpy zGKT4+GSRZ3ujKa#%IPGtIc#D_h%Y8p4m@>!png=UWIUJAcOT2Jo`@_)PM(S;EX}jg zpG+q>ErZXVJ;{PdTDL`_=sRAwC^u%gj2Q3w0bLk0I#zR=jPmgr#Je*O4EwsmcJ06g z#7_&OsFgJ#Xqa<&ei7p(9C(R}KnZTc4;cML4SgeU7|y@*+u#nH4|%F9zs}rAhXZBr zDIlu>H0d>ORmUkFZ+-=jFU%YcS!0vbYAb39UPV8ZtNI=<@j6z#iOAjY_Odl{ao6lC zl(2F>=yTxkg94D^Hk@JcR!{XJCzVf$ZBNde3e7=_CnQcJvz>rWxTJhf6xyIDw`S-6 zeja9LfSE4t?4uJ(#6le zsC+%ZtfJmw^xF;BV@gj3rbsKp%FL>-*^G3FxZpJy6a%G`R_qQi(a%lvtkgI=nh?GY zt{g@^mLgD24#+TPtlXV|0eh&dMm_FFpj`{K3G(u8#jv%Js34QqtKeB7RNg{3HQ9Vn zLj^`RI$nsCL9Sk8dyq%@~J))qH8obTo3YE}_yid+by)8&Npj)LXKP zIh8lF&;x4Nk`CuG7u|!!Tyg9n2v+#*&pMms8k9jhnyb{Se6F;dc{qN zpe+vsM=a0EnWum)WHC9k7b7tbS*`YM=x_ozD}^>4esMI9j+zwJp-b*PS}`!y(($wK zcEsyOMVZ5@b-V&~Sxa<*xAoi^Q1B1aWASwKHF!waO#Kk%A$u@}UoQIO4m@uE@xg_T z2aLBx4HgnVC$o)OK?`qy1YRuvB6LBW6ekPa%cD4vK)m+pbwO{(;3F2WxHspwbU@1lB$w zK7T&SAoSETCY}rjdxCO2OftU|@DnE{O`7QFBSDH1YK~ht%~)kOgc1+`lZpE(%DBd8 zJU0CHClJ9u^F}Nu^6WvyQ)9PPLQ5LG`W7I$yaz?1IJSHz|y)ZA(}LDkvZBDZh8*V4OPVYiT%+J&U07K7Y+1Fds1* zZw#*<1D>%y9LaObG0}Wa=d#NRZWops0&RwA?N@*8we7oCXh++eGxA1?QXbwQcm^`n zH<|!KE#l{7y{shyPz_nnZix73$A_dwbB=jk!VE7UDyAMD1zTpEMgHYKGK%3G1uHchm!k9M)8ea$#h{#2SEn40=|O5zy;Hz;EEw6FC0i(AGc zgO&(YT|T+YQNIxLu+tgdGmI)3>c{bV;V$sGm#)A^b6!h3qsG1V<&Qbva>BMN3&q;9 zbbJ0h67@YRWbY>UKvqD;Uu;b|&C{wdSZ=!7(HAyo+LNNT54N1+H@hbJHRdN!6DLY| zLR?WZ_$x^qJY2mtkS91fQmzPhm&zbS?@=h&(ZaOOJBSN3P;;^Zir$cXK8}yw<`uUr zw@d9>vK>ObL#okGeszykw2`7Z^44SUEtr=E5xY~_`9u)q7n{^c>yhXI1&yh|7h?YE z_2NeA^a->s<+i7sOlkw;#2L~@$7EGgmUrZPeC`V?VPc)1#TWC&8@O}2#Z;M9PP(fD zPlm%fKDfU}IwQ9r#EZ(?1XDGibURmfp|NCCj8dvWO_i5lSq87bIv|pf??viL(gmnp z7U^0+J@aZ)Os&dlGOAU%hdG&Ucb|%G!JC3d{LpPT&X@}fzf;{g#yg_M918B^*#=oE z37wsp!K#>6ZaDImiRD=Yed&JGS(Z&0WN2kU*mO+Csq}v@3c+Vx<_p&-Y;Lk)-jyX4 zN?mj`3ijsE(9q#bil<=0A`W(R{46%lP{6%6G2C`Ye@L%sKU2^C1DE^Mjo~ysY$0P< zNP|10*1GDIA60IC?Jk{gI9fgw6eK8j5=_$r7LOM>aIiVREw}BAH%g)t_VVJyQ!9#t zE zb8;&}(!`j#hm&P?x!1P-xQWTI>-wsC4fi?b;`dyhJ{{LJ6h~WQ!S0(KD$~;Igtfce zyU~VzH7Uh|ez?!yBlc~?OyRAM^9jUq@XM`a*(zcjJ@ z06Xi0r$th@xdqC%3Xb{hWOQ;r!63==kQ!RD&TUf+>_n}i)wjq}dNK?Bt}kBFIKgZS^RB8ZL2JCDD$Tf%65F*Bc%Wy?UB9nc}VFs0n%zKsd^!lw<>LP(xo|bAZ|E~;Oj!2LOYzO5|`qOaNSRf z*hbC3S9T1}lU0{O$rAJP=`;OnLxpsdOPcB_7{}Y_(o)9am4EgN<2GAsaY_m!+%~Le zqOY;5+)1~8(Lu@j-j z;=|tnRgPawb9bN0sC~Sli7+x>8L`FLnqZTt1GOA-5@BSukcvvNWN(_0f z1Nl{!b3(vg+?3m;srDnIYOHgiwyV-&@QQDyl>~_kILV>iTcfgRADDfKCMYo7vLP&I z1>=N$_7Y36r=;DKyF0>t#k=1Y6_4BztwYQ5omZ-(_!u?$Nxh$3D zAYb=Sa#_I%!d*hBN)ta9rJt+bKGm((iOoxd9r^Ur@lDZssm2rS6eY(zF|>#x@6*Xo z@8N>IV?P?lQu-?^@@)vb&{J2oaPaZ4+r`x(nW_q{${_QSOD=8JLL?^$1j5Uuv56E5Q`HFx5)Q0>v}Pf(u1896Jkqf)P#ER4f}nJ25boMf|B1!JAY4@I zCSql?oX<$)4|{l|mu|Iq@P|d*$0!xrZVk7AMy#@3ZQ!lXbama>) z%bzcx@=8>i=!l$DWyWPfRc2?6puDVGV#kXvws+9T|B|_z@=G> zf!%*Kk1@eMJRy}h>xlS2FPQe^CB;I+oO8%Pw9?mNXpWLu&z)1+v<=m&*xjsA736r~ zBExrm>(Mg^QFN2UHVJ}W?#O~ma2w~>-nF6PTO+kYR%UYMoxdcozDr_7F50hspx-wN zcTsj)T(Jr=i7G?&9LNl``4*Kc;jXkPFEb9&M~IE5`z4#zvbw#heX4TkgzD@P*-Cf+ zo}r-#JhB_XpLu#Z?3Laga+L13PBI#~G$>Dflp5GBh*#Q!;N^T0e@G2-gAlz|)a<#c zenhjcALyvCBco@JIMP2*!`FO&zW;_%CFhwMH7mC})#Bp>9$CF} z_KWI$*3zL-KKhtZ*yULMy5cX-6{{PZZK-J4NX?M7akg2=GdIi53UU%nTZL0&k?TH&Q!pC=1#2!@6XB9Xd$$!uvWgten9Vi{EbMVt*L(2xhr?u)vW3n=z+$7gE-#{C0_oS zROH)pkO=x45RKA>z5BhR5&~KoXvf+@C8NI4qp7zOh;($QQ&&8Y`TO|>NwZ9wx62ux ztG7=P3@-Uu2Dhk3pgENXn8P!91WCo!6_(N6;MKIA3m!qsUu@>ywW~2}*rIC(UK!nT zD7$P?E0?3jly3lI&6IT&NN+}R;8?f!($HmkE`RvLBZeb>KPhE5yDy@-Zb4c1Lm*Da zhlk}|T`WWqt6Qvy@q3Gfa4oH;qhlwNsc~@flu&@Jj%k8UK0iRO4R6p8ceIs6tn_3( zL`_*bj0EU|JHoUyKq^y}S(;+CH0|95x?}Lfk{!JWHE8zFtGJ5zX#&3+M<;JrsTQi* zzzwn0z;#b*wv2>hnZxCSRWh9tYcnYG{xSDKR66zSz3u!+PR5R|4=l`g-yVYRX)I4^ zZNId-z3MaJ=+V1&=iI3cXpz?H(bXK3Iwe6mV$Oo7_jZ49U$|Ub7z^U*@E(jGW1x$2 zRXAl)5p7+xT5HhHH)u^``1yg8O5ZDMlBbIzu34?(o+{2es!K_!p_;&Km~L5GdXr-_ zvrB<+p)pw98>EEW6|ZM|Vks}$!g2|DCFN}w%T`dkln^=i@m`N~VzKyW;AxGN?%w0v zr;=^O;JE}b%n%_)dA<{;f7X%?2s9co<9ro?vS0_+QJK2(9|s9u2IZZv>@-5@T1Xsh zPRWnbrJDT$>DIU2dsUiyQgx{WTS%~)p>d?OWEUQ172+K1Pk&}L~}dN`)380 z%yu=}g;iesF-R^p{N!4-bivqNFi{F7?tQ)$_m=2Ycu!~~$tpr3QC&OvQd6Xc1a$6^ zZ8q%4iAy6c6hf+DglLMo_S&U$c0R0lla9R}?c%L23w>(M2XloC-|VTGZejY;*O4CW zW%O_v4}EY8U6e4oU(K|>^mG74jPJ7|`6dQ6D}#pRv!B}4w%ILeAtq3XPa2I!MvGdD z7F*Cnub&4FJAlJPZb-sfCEdYg?P?rv7!pJ|s3Ir#xmKu-=i7`o8IA~v0^g<3K+5lb z97$117vv{DX;x6>)pP|4Dv*3TdtJjBbC+ua;D%f%|9&%*6FYq4xh7xP22E%}ahT!B z-FqauPK=BqUhM3MLtOPj6mMnb=;X=dG+{8nD8MLNgO2glRqct=Du2Mcyv&bHh0Aw` zJg8k7Y{RP;b&}Kkv8lTXzLjw7Zvp>PnU-GY|Is_@tG|P5zk)Pa-9cO$!M8;S3#Y1Q z-BVZdj(X~`poLh1!5-LBH&K)%b}J{xsB0Suqin6g5c(SmlLhRPts=yd)wN&!K6w76 zPx23-4!p*mCCfXStu`V z>jAEzukLVEPcP`xWUa-5UUHh9Tt%LYEHo}69S2lP*v>kgki~f%c7L6n;hEo<mXIe-^aHj{5=;&X?0!B;*e`SYMGB&e5P zm-gD&BzXtqeukWse-B)C07{|G_qu&GQhJkb!5he6&1_Sy>vEswPYr>8o7n0_$+Qe=$8gY;ZznXc7AgD{3EX8PAzEcG302K{e%ocWpalOJf zBDyk1NO`AsRAJ&}psFi?NN%UR45rAVFTVTEVPTkJxEN3!VW5-s_MJPc!7_- zGm<@BC8;Iiw%I<_z*!w!?C~Wl^LRG6bi(&_D6S1kTy#!sq8gLW^hpKf5FLHE23h&TvW1|gc7bf&L>o0_C|R5RvlUt!P#>VZ4_P!F6+PP4^?QDa9+ zd!Pciy(rjLsQcR>#Hgjy^?Aw*r7&3)h^^RjK5^g{B}NEI!v&|%ygX}DJ#REJx-%N| zHU+2x#1I~FO&03}QSxotL)+EV8+~(>nVit@ENy0%)oSa?+++-gWISo0_(DXI5PSk0 z;WGW_-j7aWUsuP=%7h*<-5sVnHcFYi^|<*%#+j@Q2^z?HxL_7@te`*Sht2uEx;AGb zww#=0k<~A6*oYX{)3vuwZWW^&1Ta=^dnd(e~qG z;m$<3LergG7GAvotH1kb)I!d2lh3I-uvO+~F?JzQfVy4iZe@(VuT5!#*4fONc=zdv z;^n6^MZVfq8tb3!N=FW{M~Dlj9;~=mY68u?g|O{DDL-iF?b#|HY29zkoa31$Q|wu2U=tSqT1CuYVbXak(}Wd>VOi{n#mrhL(H|)8(!mZTD*@xEDPlmJgk! z4d2rvGVkZ$8Z_HC+@B82TUk!$k3oYUDepIMi%=SzG_c0cM_6kO=36TSInZD4P&iLs zFZO{2-)R+noeE8QR5j%k!N|G599EGAX~Zr<>YS;h=tnQJK6k75 z;ND$%Oi!<0OA4THPXNo2E_6V#nb|-g4nPaY;n%@I)v2c1^p@S^2v955L-hJ3vaW6f z)mCsx#K3SaMH%#3rSSIvgM-GsWX0va8o_3nT{>`aK{}wkO(GQy-J&}xP|(gI>3eHR z#J;@jnc5WpsAKg_vURGlbD{gvq_mOt)d@%;AKl%es8L@1yfHT(g;EF4z0iUKY~U0A z!$1CLL6nRHUmmh5Ug7lctYNx;J(=RMeh+{()&&GmBDADI70|ocGf`y0Ep|3&Y1P98 zE+T&exgFz^I5;?<6;q&@URyUbJ+2pX978e_8aq-S~s zX|;lD?JV7c&sCQ{0VqPf8mCdmsVB41`sTF-uv2i9`tCu0Kgiq3Uq}_ynza3hgEZU& zUo6USpXm#J$5P&(>lRkM2(j$}xkyRUUZwka44 zahNFhe(U{ma;rNnw^Ev27H-42TvXI_{W5u#h~C~cEV)Cjuu>7sH#yD|9i4J^E};1* zye4yXptg7(z(Cmm5y(thjj~x?#-IRA_YCN!DLfom0PHw5QEn+(>IO1)6NcWy)#*xz7}nKaCykmwg^yZh*x-% z6#2pmz)CQ%!qc&_+l!);iB(9GSHDHr1q?Yh1$u2OchBkeO2hDM4c!X#&g&o%Htsn$ zSEIoT+rC3(k;IvZZTEr8I}b#*I)oirxUB;_)7kUwp{leZvYSfWM058#6L5_fL>Hy<_N7T8}{swY*miOF`$D;eTI&f5-+-KJ3%x6@D zA^-YZ_Tv`r#lwP7pmB_6al$s7U(M%srQ*geK*i}Fj|vU`4FXrD8+p6(3j6gl7{vTp z-kL8LrxB8`ZBX-OWvf!om1)-NP{wi$$BcERvGk6-u6T51;Kmya`I6`&YP1WGBr&lY zL~0f+Ku2(&gQq^722>S3WbkFePl%8JV19~PvT?s4y)HwRldRV;%*-Kv<@zcd0o2MRjnAHF28QSgcfpnqQDcdNp^tcu{j}IIHZnY;B+1c zjpqQ$&=`Z`-BVP%$%CQ!OpoztxB|xP`)IfO&ls?+&s}3>-$Ot)?FVPa`5}f-mu<={ z3BMWu2lh@23^!MFo8P9wfc|y{(23W+BhF2fE>ne+_rpr%TaGy&*fSNf&zt9&qy>@W zYT=dw(5KJt=UXKKZ>8+-X)%I)?5pn(+6ML>D12}ip7JHSB&o9)!;!|j?_wH;gS)5t zBOFhD$GlbFs_j+)-`f14PlR6upq#%U-I8M9tMYj}xi-NLiS>m>y04u31C^Dw2_Wj9 zj#2B>)~}~QtOS#@N2)nUMiE7TN(0^D#v{K2;UYx<6=fXrJ5;T1l<#-on}j`))ho&% z4IozEad$g(0OLg-y@NgOz~1)QYd0DLoceuu`S62L&1*bh2=_qh!5xAApswM#PBL~* zA}(`h0RHN=L`I3sk>o!>ZQvd>Kx7369OUGNR#9=nRrQYkM!e-R=V@5v1mK76UoCZL z(oe0dgcTS&;0BZ*-+_ViQuiZbc3CCd7-V4ig?TKdfNetGHY3jfOgwL1iM^;~mG6A) zuK4ARa6>`DHJE-!uW{RvSTL+w55U|IzdOIf`N}_mM&Q1wm3X#WjdR}5)m=C$iDQ(T zOYoM4@Uw(1fQIu7|E|UsS)UbN0IiX5T!MgEdmX-YTRnaagUh|b@PXu4Eg;f^*D%Kq!z6`c7#*1pa%o9eFx0N%SN;Fq_CZEtgXK{eS*UQE-)kg zM(==hGYj8rx7t2DguNH>?9qzEUf%vXH(tErV$)vB8xIx0M0|^I&4Wm~Eoy=98K04r zNj}pZsH@iG-Vg6L>>rH^G?D@|+`wWT82LAh+~`7JL<;h; zxM_<*^Js*sQgsTlW#E9nbz?FHTLyr`1L`d;c}@=4j7JT%ia&mncmSUN z)sHsDn3u(tP1BeTH-{DwQ4V`?gqTeqkVsgW9!|tAJmTpU(mxkTW8F~F<*jmIPGSa} zt;R+De4gU4PH8v>pN8oT@sKxLWudLzDDekCPS!$v2)*61m-vH_9u~+3#Bl5y zB|*V01l#_KMwb{LS|W|VqqFk>J=S^5ZcK^m{1v(K7v~BfF^(PwsfH%BsSiYe`)7HH zZqXLg7*_l)V8s=vGaer(;JME)FNGC<>)UGh&;%@YJ1&zVGSatxU2Ev9eSC{NBvK4o zo;CBjn*PcOBlAGSHEO8;0Vt>7>^=R%4d)iFd(>sr#zO}Pc@}D)8qQ@O@2X3}DmJdH z$?%-uSLbpu)Y@LNK0$OY%cHg)5^Enoug^);*+FRZMY{Nj)s%^@$@FHhMs6}?^ge< zG7;F4eE`kC0Q}14uauc|>w5bXsKrUKGQmGqerp;2C}=oBOoH5iYmtv^Arzb|2EZ7x z`K66r5Pf{OjSHS)FAkjH6099UjY=+FhDs+R%ZlHcd324N>rX@y&bcMxKvrI8r2kzU;h zGdF0V1WK%4r!g-2Qcmg0t-BafZj9Z1N^p{}hE5JNWLnt^lRu9P1U|{N>HYcR_4NH2 zCL7MIKYC~5rSp6O-oIWw-+H@)rv^QAg%nFlQ#aboCw=3Qz+K~=+Y25S%pX1VYiG=W zkBzU5izjZX>+1R~r!$_KPY;&ljLR2m_YBFmoE_p^=Rf8U?5SrxGcC?tz<4M&BvirT zk*sMNt!ZE}*ydVhHzz>Moam{Z&zhb~KotTwYdl$qZ<BZ_(X}(C#XEDU=m~PUGA>5g8qd+t;vjC`MH&XRF zQl{sJ(_jkveh2<#>A7Mv-h=I_YWj;IxdY{w=e7~i2I;!-TUuj!s~6^YK2_1zZ+>Ce zm6d6k>Xf>4QP$5?!pumV@I2(3?exl9T8Gb&@8vm;PkDudR@Td_Z#kz#E7(45G7HEJ znQM!?-V!;;QZC1WWLi4Se-NAGD(ih3nU;>6BWKo)ze!yjTFqWLZfmx-4tcSu%~)gF zwG7uc7}7Sn2gz}tG1kn>K`EwdjXfWXh$_dC*JtxwErTi4$+ks5Eg_-lIMeYv!NJp& zA)VR;Gb13O$;CDQd2-TdasM<2azv&=ziQQc2)$V4=RPO!i6DiX-xN6FQZQ@lGBPkp z7CElR!kUCLT1lOVo|?YJR1esAg&m0ahlhK26)xPFEU*nMyHGuP;Pru#_OWHE_3|`o zOb98pF5VZg=}K+yRL(}E__&Nr`^wa_vcdvhS0`WBtlHWS*NF$5?4Cp&v~Cfd(35y} zy=DFsPUiK2qsZ%m%WKP}wx?VM`axF~%0(7#oouhiOY#ltqB=f?2aheO&m0!Rebh@8 zuGmnlj=4c5rN2b|$}=9(He1*rZWBqpc zkPL+nq9p6T96aqZ2M^CH%fXC46MjldlY${nckD&u|Kj_9v(h``;5lIYyxe#C7ah|C zf#|FMIZ;Iu2~FVWUN{%fS@*BC@m)nR8Ie{nt&#kfIx(960Pmg>ONwF2zo+8A)cfx% zEst(Mkk{v)DhB|2`$VDG{a+*O!2e@d ztOLq_=XU=&TrWPy1pr6;1To+Lr>Oi_0r;0I{l6RYAKWG;<9E0H|76JjIspH}m$!pr v{_IEpQ$zj{egC28f6PCBY4h)cF$7j?#EpKg2{0w_&&gxDN3#!~zxICsiK+JH literal 25174 zcmb@u1zeP0_b>Vk2&kwSNSC4_QX(li2BIP@UD8MjNZ0sNkp@LTrKP2$rTqy5N;gO< zNDbwXL(QBuzVCbQd(XN5^PY3h{m5rv_OoN{wO8%6zK_9bDvC!Bvm8bcTviTp36HY*xIxR5(Huif`dSRC{Clwg@C@q5CxBVFR; z^@GQ4=W64+LT{hqy~N(CKQQR#=BD%Vtb)$qPMzIgSzb>(#U-uF_vg6;UaL_aI+y+` z$+csRSXBI%#!P~U$X45EawnG4xKhQcZlKW+@goiuR&yTnRA|LqEQGJt2TV$;wJCOB3&b01%8W$orceUc2obqAKyeF z2qx_Rea_qGTl!AG<6A|4CjVa?^-oIZb56l3N6r6xLjO-D{U0c}|Nb63Y`}(|57vr$ zdH1CheCxXWZ`=Q$?e8Cy{)b8b3r_#rsQ^plpjgK$o1y8jba{}-hEhjYpK7n5m^ z!%6>t*y^A8U*3J4!wvikH~a6;>VKf>KWz2iDEQx)^xsDP7tGF~|NoJNm((*OV7H`f{C@=qXPD2CyJWA$v@kkBK|2>41$p($>FTYA@F%#oc5ZJI+AieoQ-YQiHXLZ zo;6a5oyx|pwR)=Dda^}vDq{A1HIExh3Ijc2BW#e*q3C#`x{#GpN>dT#a>d8laKmPA z=kGU_m)+=70z!=Ha}hM{++vX5KxA6E_$rMC4j24QbLy|%sqVKqX^3A2f-YIOXx15w z?@lytkI_&>dG?R>PV!VVa|>m3pw^Q#F)3cBPwd(s=lsrl;nN1qyS7ccCiux$ZKfm9 zcedS0q@Fv{KbY-1YhV{qNfs>EqZwv#KLp8D}Ub^C%}dJ@9@e zDPv*m3=`5a5hLPm$9}AM9z)KR?0hBcoGI#f11sawO1S0ZcrWD9PrPF@lFjKw3X3U_ zhxS(l{nt4>Na$Uk!A$bngwH^?>r_R0mlcd!(Faq|JY0U_6T@t`+Z8A0EdD%)Lop z?Wc&s1P)t2m2@Cw>-tk(S)2Te&}S*^=Z|BHlHKC!QrmT4GI_j3XYp|FE1~13u<3pB zgNidC&xHxQDmkDQWwGMaxyrq0zfPwK4O zv9E6^knoQU@0N6+rD+_TI6W1Z)q(1Iy=g2j zSL%v89>qz8;O0tvuz|Vh$5363z{2E4qY`FZ=cK+Oya|02rI6L`k-)yNn z#}%zYk7S3oH#PIPE3;FioIb+NWX*B>sqYyk76lY$nDq*mir)+K0OW(1v%Rmc2CQ(D z3b9==*$g|xmjNqiL=0VW*W-Z|o>CxU<+F{FkD~j|Ao|KDoO|koZ65*8o{()Onw44n zvRth-?l_YFu&s;G=!lP5(3r)zTf=no(LRksra)L}9;HMu``}%6is3|4(`h`&`1{N=5Iq=-v}5Z05D9@2H0=mn%WWff~uMg1%}H_*~RiAmG3&>Iq}z&GXE zfERs-QrLNsE}@mN3)GJ@fj61uCVb*7aZ(3LK?!#phtN|2m>nta5$L!CTIcb?k)wf{ z+;L!2U%m+xgko5U97d#woj&ox@uk!OboIhwkcSh8e4UuvF&l1eBqh>apJ#w61|Cr# z``%WOpArk?a>Kk$Ok(FY4dvYq+CV6?j(w9_)G7t6u$YQ!*Wkg3zems}i`D9nca~5# zuk(2tFEwbsz_|Zppx&8eXU$MSqM{?tZhpTE!x>@t@VhkgDJ|Il*RXH2eIXP`?M$@| z5?=B1lWsdDifsPo%8Uk;6A><@>Vh~aR4Eu`Z|n80Aq!vNvfeujB1KsWf?ipyQF%;H zrsCnkAz}Lmz}+1fnz%G@4~FW*L=3rv_nk@E&2?U`l@dSSgy)E9AZ%B-HKPw4m^25K93S z6vp^B@&NOz%`7DuBz)k<-MnfSAnv~1Ajr6kF%W1B}aCp&rpd#*>4s~=?2*&>*1%7?7P^N!)TG5 zH?3%s%A6-3Ae>Rge&Jt#8_$w`*EEAUUELmaO; z@fDo4r4~5~)&H9!Srkz=|GQ_-S(0@<2B>S1p+Zs~wizSARJt0LCnAse%M%C1bTuF zg8IQ>*vd-tkk95i3PS{y*6-v69y}P}EBf5~J(ZJu-q9s85_DX(&DNPJh(H1PHjr=D zUF>P2e-F3nhxVfN-q%gKzA=;lnxDt0WK@am)QJ8UI zUy9oqSz)^{vjiyCtg0$mLvx!=LbjE_s10O+YPG%LRIN_{I}q#m_T4`d6k&bK@#YTegbmW;F`YzS67sQ8ozyHA=7}gB*UsNy+gr-pcU`@dXR8L#QC2 zkN$8}N5Yefa$Zio<>-@A(S+FGh@?0dH5X zeaq0>Y2Ka6_A4-FEMx1n(HPl&msKZ4Sjz;k+uj8#e7sBzXC=8X-crFN`?JpKOxnX8IF?;ENf+e{^soFvITyfxwiYyfNeqC6*gdysG zJV)FWkJHTNhVC;}ii^5bbA_a;9K(<2z}nh@kMF4{kF-piW#EnP8 zmigwEgM~-~-{iX_fv(m_o;bSNC}{89oo_FQ?NWnAB^}iSujkE_lr=F691*lD;Iyu! zA!zn?VUco!_v6F}xRlDp{^`OkR|h3hDV|de@iu{vV9)`(E4G#M21`#!J!szC?w* z+FmOq%mu_n*1iFTj2UjDeSexY9J3KTjHHXYUh|#VTMgue6YA4v#sgs$1%lPyZ~-s| z^>2(V2nrxMY?n>MDe0s~P^fVmjp6$zVi>&$heXdqH2XKP6EDqx*kSEUluGArt|SdCf@fh zbiK-K4-Sy4NRWcq^M}CcUEg*O*OYns6F&Czq)#5gjuyC!NWa(PTL~@?uR1wH%sv8U zzdbwb6TXPh)!Dk{7-?C@c0{yxB(up{@X;1s{@<`dwfc=oKWT;+PK-}4!9AzuWiDr! zlZmE#bt;AdD74e{@?@*@bN;O@?5hUz7hdA__fqoObO3hLsxPjAmn;oCLphZg-&|cG zFra_cjh@SAOcozd$Xn>m<#sG34i5SbYeb}ny>X3s36roSbWm#5Zq$p5tB`*yMbqVj zt%Eu8p2Q6KUSBNsFB)tT_SVITqQ#B%Sh9@u<@S7WZ0KZg$j79l&8GUz7Rt;=%0iU~ zgTQA}qL%EMY9#01uRgB?g2YT@oRk@ASQ|1kL?6A3cc(-uVB%;QxT0_rcV%TBy z8kVGk1?y8O&G)jIL}avCAi8h*`nQ6$;*spe2eTosGK#p(N)8?C!-ZBSN0bQiF$$?B zQqFNEle&luLXlZkSj`gR7G29T(-h_-?DU+YwUWNoeOL8c+~}Gs3izwTj;3yaud=Yl z7ZZq_ElI_aF1vl|`i)c-&7F??N9j^zAEzHfkcWGVO)ec@`p&#F)@#}-;0wSla}GmZ z0-YJ_f#u9fKh`}8e*l}}#z0KfG|LfGRAH7?%n1b?y)rH)!+EE$l_nEzM|}`Zqw5y! zyhKVEo4zr|sdn5;q|BzILNLDh!c7FyQ5g}wx<}4e7h*u_cGMtOcK<}t?5&&|qRBLw zSVkcMMnNSQ5FbMMH|lF|`cd;QZ-A&afyic^!i#Zk+^aq#Cje4dw7=&}a^gzbt#Gi@I%r&?ob2leCPn zLT$>4ls1yEvl=FnVq==f`Mn!@QokrYPXQB`76}PWB+JHtF*RdfMWEGH6H0^i9*~Rm6dz_?a*5wx2I$9 zHnLixX%I=O_L-E{iPIV5l&gCpS3(8u-DS_w!N;k_!pnDCV%kVGvjbI1KJv|?-kl=# z_z2RhV2Gv=f3l}AP)=5~(nxo9Qs)~+9uu{?%RJC1K;BL;G|G!}t5jzvR9u8cqd$%1 zY@~_Z#|d5!D=oZWGmz9kY-k*=66-gg5CU{!AyMIqEGfWd*h?n}q%ZQ=PSV^R6Z8^! znHq6c($;HytzF9mStH4JPek8XI-C^efgLQk=;SX7%lMIdmb3GecM?c3=bd-XPv7VT z&qTiRqV1&px5SxA^`i1Bbtwtr($IZO>&sv$VxuQrz{y&HC6MOdI=4KZZl#4GS02Eo z83s72Nh<*tI%?7lOZnZ7ae?VV@@<yEH6!py_t57rVY@7)D zCf&IV5ID1lhwWn|P2-?mp-Zzu;K#luK~KK#re^lyXWW9utCxxhk*zJ3$98GnVaQJ& zV)3NY9?f)x0zM!yqBOAU*do8&a8hfKtCL`79WR8QgJh{?ZeAR|J4ISfA}ni7Yi+?^ zetXA!JbMVuH{@?-UuT6ou4Pblmbl-u+K~gtxW^8u^U^?u;eB}z^#u28+l^t6R~jc< zSX!G@JSnWwxn+Br))n^oN2vr-w3fsiS;u?LMLA<*}Nd*5pdOeJYaZLZnwZJ`cP3GpQGnx ze@}->dj;IRd3$+b@rOGlq|WN|{$iDn3plz?(j%Ce;%$BBnca||Pr1)hYfXN3Fv~E- zGBZy5IDP*$MM}C-=i#=!>jRW#>yv9Gd;N~A%{r0$tz#t8uw}zg>Ad0(x8gBxWm%%h zz-eov#br(#VYv2{NIrfcx$)KP2dhB4W2JjTujq?Yn>OQk(qHGDd#+9=bAVq^6#&x927dG8+el_~JYiL;@+Z zBTge2QEgUs6S;C-^R|(_A@0s*zwIT`cgNZx-~7Ha#Exl?rpN+;#7(+VDJq2P$x)ri zk_bU4QkIIE-vemTocJpsw3jRS_mSQ9$t4 z&e!sd4)jS4QZwiqwdT2mzB39LRaqYs{L=6NW=gTmaYBMCUYB5wVK5&ovwqQ&Pj?x# z#>Z8>Pm8&NUjV`4`<_4XFLv^>KX^;-I}{Nc?y{4G#FZMwwF8rN|Q4iBQgs@ ztOmCV^w`n4uugX4#kxB}#JfpTB!_sbJ<=R49!uLoMfPuGcb-yr>qxLj#3k{CX_5R@ zHj>1*v;fk&uJ6X+^lAY)KN=v|%5L$BFWhqw_@mGdrpDX8qU2KSK2 zC6oI;!=wde;EY%Cz>|05;&+-!w6TrzvvHb9sticV-+2hqckcu~bw6ZF1-Hw`M3NW^ zU7LLE+RA8^YTQ-E5)%tayf2-S-z^O4WGZKwU5Aff zol!j75MYZLgrKC0ab@&^oY$l7@9IzET3q+)H>!%fD1kF^O>Y@r)5#8g@;G3l4|1_W zf1-Jbxp_&i33ldv};uJN$#3Z=4gD=@X$JFUyS09jO40VeDscgvyBkyWc@By{5 zA|fi%g}z?bo{k?&*@nIKv&e9%_^TE*P?z~CPm!v2)mff;UD4ppNC~Go{!Z7s)rkq&XEQyLDlTOKzgp?FOe`FmzvGk*9?8#ZyP0NT=K_+i42x>+I-E|HD$VYj{79g8*;- zI`@ZQ=8cTmQUrkO0m6>=eYXi_?L`}Yj8i0*s?9n=#|i|Y-ZkRr5va$XJv+e6Xd+d> zBwbv$@(O549F8P`BPD0&(Dr323GR=D?bXI6F81O{#$p_QqK^rPl{80msJ=d@Kz=z^ zC{IpAT%;Z#x!CNPoKn(h)B)SnwsNKgDT5Nap0s@yVNQC%ZHP2+nYdCL)~cA< z|NA_uSo7i6EDm%p?^(G|Cio;H)^&GH>&9A5?h2*l*jIGsa6|bA7C;PQT}dGo=`3GU zcXv`4Ql=GrWL6!$c_|V9wjrdp+-6id$?wM7pL_LzFGw=NvMTb02^Al;3u&T+3^16* zBj63#wCtseoL3~>yhjr!k~Rbe!vzL%+Lgw(?(gkBpPsZV^Af|f@givBrm&!r2)+T< zi7pA*-AHB?lASoQp4f>`)`47;n3pg@k8ttW6`UmVtU}|)glH$8WM$WHwHL6>j*rfD z$x}r(Amdv}R#j(vP}Q^v3FvZYcim%eqW2W2^WIpZSTN;h6IhMGeOyvSs+dVKrF$iQ zi4f50wzF}VuWB;Bp4Z9$leib+DL{qT1nfGUO7_+1{{Heue&<{Jy$L(oxJEs)k{aYn zA~oRbOH0m94%OQ3@qfT6CvtemCjCm(#LW@{Hu>;a*On}k4|*I35sYSOenbQz>uD%u zxe~)?LVZP2BD0B1MS@s;pff@+IE-{9QPYJ*k83V|GA%~DB1jTTa4C9XJ*mX_Iy83R z3LO%lih_vzYpU~(F!q97W?R%Ofz(*LD@m+(yX!mE%Nd2t^tJ?ybS=u1_|5OIqLa&`Rb zETQDoOacd~Bgnt!t4@4{{oI+jwzmGk(tJa)^WYFeL}|&CkV{-hcI{d7EWN7 z<1;$&m!?P+8$-0tO_kGo3L8fbd^XTPFxq0iw|((%%+|^@XFbE=p4BhEv$fegX~!7X zs@*3B)1>trkhX#h-SkW689cTsH?srJqS_Huog6eO_#wXFw1Jicv3|LZuzVU4k?y3M zNP81d=RLr|Xq~7Q#{O2Sc_DGPnXQ@BtOFf?#S*Y?+xrU$`Mw5)<^t9%4x=Ni3D8US zD)(~R>LRIvowT*yNat^2?B;QU0;NU;9nV)gvXHzjggYevX&4k0bn+jwf3_VK?uHDN^{LZ#HAl>40&XvA%7WvTgQVRLZ zYTz0}7*Kq-vn+pw1McsqE1J`KjkkChC)g&4Wz_)~D{I>0U18#Sd@Aw_;UJMBr;VJ6 zza#@(#z}|&Tuk4!5=WZk+4Ucb?-Lu_nn_5hAh3f$;Y9QD>E`7zU%DM{?j~rx2(5?N z+3~?QLMY2s6Vs%~jnMVB)9=rc^M2T4teG9^WouA(ve7$?L=6RjA{K!nWC^|L8Q z#0F|DQ9U2hrj&RX?D7(%6rGqzlwCbwwB8;a2oj0l(`HHfXCn?8!}EEXcoi9M&}4LU zE0Gtgx@(tEK~oy%R~`}I@1Z__FEK5SIQyG3P8rdEcN$6QJ~nn<&IPSr`onL1n>?WI zF5xmIOGX;Eockl^-w`>k>)$j*CLgd*S2VywDI-Cfk z3+|2J6E}bhOx;c^ffLbBra=0}-kfkA2*yfHY0l2kOa&mrV6*xL^`|EDPK6ewY5cPh zvKoEe3=2&;%?u6HFsnS7j#E@fcHEu#ruw=HrlH9=X7cEJp#fJY+i_h+liW1rzsM2M z-ZygJ)6lo*eUSNPg!z`^z7HwL+9YHI(p@M-4DOF*_vgb@In@`-EOBgNE*(e7Bf!~a zzx|tXidKJQJh8M@?Im4k$m)WTSxuX3WRpWY^UNjJD;&&4H*M|84l9btjFm6>5~NKU7$Y>tS_ zdFg`Pmr~46^r;;KLO_k?(zX^)a1Pb8AKN9dcGl4cTp`k=_-PIQ?q$s`0sBs<4xx0v zotP%qQC$~y#gG+cqoa*r#+hX<%X!={YPgl-+V=u4k2ruS_cm>el3aRb7G0(>n;pNl z2osHgTPo9LXSc4a*EP4tvAL~{-Xrr8j_1B*A|9@Yp~BqIo96`yiGnMEr;mYhSahU7 z;IX@jX-P@(%mLm|r$Dmh;SsBk6FbS*^N$pDfY5$@MuF7C-0%pd}$k+x` zFEP-4f>ZXBq-R#&RhBOVPK@tGP*oAB`9PK+)B7ym{%|6b#lBg!v+z+Al~65pDCGoo8r5$kJoO6-lJxk~oa~ zW;DoS&0;eln@B}USqTwGA4#CuhMx)v3L3KPB@Cv8Jb7z$l1#FiRr2O%R^nbGO-%x$2^{Njpy0Et0s*;iHIg?#Vh+7_3KR>}fb9Vmr%Vnn( z3L9u!Y%0%2dIyws&qPl)ypUY1k7N)Er>Z-CFSe@Me13IqK6pEnxH_@8JstL4*xbEF zRHCX{bC#0j>%kNHx6~VhY3LOf!5HwdN=XJDQ(>oghc%%-1f`C>wl_Q1Wwx(x6G}Z@ zyw~3xy>0->vHa8?=46@TA-j0Uuen!vK9pI1Ec4sKYw)$FvkH_q*rB395qKmdI)bl! z910~|BC4H1Bb0xYU60h z3}RgWsW?GQ2u9l`Q6kE-bAD5A_GRX1ZBPm)svS>^INUe9QdzS&quU)LbzJTFWLKvF z3S-p`_Ql7;`LhZf`1*L*cl^Fd9Gx7Letx@4NenNo_c3FFg04|crcZ^+R*-1oq*LpJc(8MKapuy@KaU_ob#NX-;=s37q*w z&vpA@4+DOF>ohrxK&jDG+jE?1I&HG8b#;FluyKPQ0lG%7z*cGPyQ~Y~HE9D^L*J=W zUJ$7%hj<}>E^YP;~1{KK(TPj z$>gh5l8-AuEP_Cv|AwGnbHc>F9tw6yxE2I+FX$Cl;-qMfs~4eAELL+Vl2Z-XKs!i- zM|~CEJYznCO#BrlZrL5%r>vB=o$%}&2c#2Zn{1xlwe}?v%tU4%AUD}a zDibsq*nW2P`=Cw^Foo){kn+@18fjY8tDI*Jo@&-B^4UX{Yk=H(cSpTZzP{p3b&Bur zZ++@q@&G=l+x#wfY=j#M?=zb7kfbunlLN%x*GLM)t@L{pJ|N;zAH!!>R$);UB3jr& z4XU)vfNHaxR>yCj3b_l`1W~sCatZ%!%bQKRoozR_IMNu+8?%VnAfv|$FkAmlpf7V zQS*QjdE1XC_&f0d0F{i1nIw2rgQqE_A&DcCfFM_2lrPq(1TKLAUHYc)5T z`jAmzJ0WM~b{una)D-^!fy#fU-UxXT1GEo+xnq~8@nk^Xpbez$JKr~q9qxN=!aG;(vE;Z%r)H8H5zH+L`(BWpf; zZs99fOMG+mg70Z2TddHjb!^>8yaGzJWA0_qwl`2!oqhAI6h-N~yVm!HufW7@i$%)# z&!?94Q>ekd7djOAA7LlsWvtx4f9a-GV};d*1r3HEHnX`w(`=Pgd$5cQS)*52{P9v0 z;a&WgvrXFv<%HUj?%|@IAUKBNUoStaL9uMTBbF5aHc`1+x0;_h!_s6Jk4L%T1cfO2 zia{QLeoEy4wquGuFK);Ol!Q6Rb<5R%ZC_1`c7^~eg9SHEdup)MiaKg&r`+s13HSitZv$@5hxH8>4d2!BV{SFhy(Giy zoF<soMKXv9aB?5QSjaCJ6tW2Olc8;7Y}ji$R!pX_4f;qnIB@}p z1wOnHM~TLqxbZKn81+%W1!4x7#LvMHxDeg9L}!U$kaxGS0MX+EIG0%oFItni>n(6N z?+>VyTzhm;;4^LYXw*TON%Hu4v5w-2b}BAWvZP95c@Bu zI$5Mq>818wGPA6DUGn6n2Gv_R6iE8oTyOW-AJitu^3NCr{Ad^~b7iyZzB$vJ5E;Xk zngjf>Xj5R?AtT(j*USS|k%#<-Y^ql&>35W&$?JEpZZSWo3;&q(`6^k8BRlygGv9Wx z@@Y3xMmDP$fESHQuI9KjR)Ho#@N8VYdnQ>P2_NWZ-;#d^m@~+LcrSIlFjF9-f8JI) z-R0N&SF=O6ndU{zt?Fsx+`>PKk5%3xuu<3~Shh|rzaXQ*0G47hy06Bs|HsNWl3e)> zl3-3+z_G!Eqn;ix=l4%D;tK_8BCzf5vWnP@PHxPOkGr=Fkqi;L1<<&(S(6aOWC-K9 zCYo*ypVhc*(iJDJo*?QXkEt8v09oVh`EGpz3p@+PAbI#U==FFm^!}J zPiDmmgx*jUT}x@ zT|~KnKEPrTg zfycw=RS+>y6FseE!~}r(;;Rb_EmQz#!-zu!fAqEG-lPP_kt1rXBG%0-PGp2K8Qq4T z({yg8BqQ~nn;01v=mJ!^6i(T8X0~3CQHbnluw(LA87dqo`MbB$@`9gw#JMna2IVsT z-odoIedIYFZ?xm*ddYL-t@)eO{d!=TEdo>I@J}f+zHW^LPuKNVrVQ`_jJ5?Dg<etA_9#WB*CwiMQ9} zcfz!jFEjEZpI`sv1mSJTsgzXEmoz)aU^}NfutmL%^+u0QK@;z*Kt>qA>o?YvT4^`{ zjh@20zt^+Mp?Cq^09t1P0e!y7YqEY)rhMC5j&u$e)3^=vRT$d@&rCZ?; zSaT)%a7H*0^A=s^c+$t9bc%Pk)IPGwC`2LMr?;dgOJRN?Y6VIDheuG-uT zSK4E@6RA@MtlC1Hh<(w=Q|Ir0@n$>cwq_)}pRsBpryFJfNH5HA2DPPCbB675@@`GYMo48dU6F(}BqaQ-_ErZu z>?!gap+{NFt)>&ZjJql!s9~ut3*#i#IAcwfI$4jxho6jPXw3AU@r9SQ=Iv$6M zc)=NIup;18FCPoYEb43k=<1K#PMI|en8z!J9v#4+N2O3R@Q1zNi1gvOwBjd8c6-#> zWs`nDSm-rM^Rx}K=#9`|yC!ctY>+9SvPM{ly}X70LJPKzFSC&pvdzMV|ku6l1WA9n5O`ye=A1^$$uRaK(Y`k7UfkY)o^pW2mZ2Gqs5C8LvbN|-)KfQ){Nddeuf^PU< zeM^xX&%xb8?%in%KyJPk0$e}}&riV<08-*h%haS)f{ZJSxw$#IE zd)Sq1VuuCXa{C2x2Bp?s9Er6M$SaV~Nm#=QI!G;f;+cs&cyN^%QQVf_ich|MYm72#>hQ@A#wi(E zjzl)CBf(Yi67$R5e>V(!sO=sj)E3gFbVj%iATbEdJzR$&M2b}XhXxB0&oIEt0d z@u(6gU-a&xy}YWNgU9jO5^g>5oh%KjxHGbqErt5gmM?-`^@|KwYRAHvz1jmj*4+1Z zx&3#SMN6r;&K`i|{^65VOAKS%?mFrmZhVvj{rLhT{l)zfrE{KkkZOM?uE$x^D^Mqg zlgsleR18R^;lzJi_f3f8O!JXjzMy(r3D56Fctf11*dsnw-x9ujstLEX;v$lDtnRNZ zZ>uumv|^IiHMAoag3Mp>`b*BugCSYmt1w@BeNVsM{b`50!kDt{>gxIvk4kALP5)Sv z)%w4h!gpRc?f5UKu3r_|Xik1)nbBnFiq@&9TNjupK97?t$h57KbF1Lv6~9=vf~os0 zHZkC+b{Ji{Iy>kuhvUGo-SUX3yn-X?nwy8>SZ~U? zlRItAqb;m4alNiOO_5r6itNVi^<39)`z?zU)U*{_b=*F8zvOeilZK*oU%sKSUzyyR zu#T>cri1QtTZT%v?*R%3fq0Oy|AryP|bsLuB2nF(BF(T%js2uJ> zkJXTx->T!*cloao56@(r-{}fCyl~IbG$U{Poq^M_g$&lxigBi9MqC=_4!IJ)tD81ZlwUs)tt;i49wGD}YnYvYndRJB8R`(KLTSs7tf^iZCHAK_!;p>nj>~Kd-qe=!)URc;VqdK)ae^X{13gza@f4B z!lHk|jb^qo$2d*Va`MTt?)+V)D|j^0b^<{}I_7t-s!P1o^YYpmdNJ1C`hIyzydxmxQ|bu;EmU@a{~rf*3^(N@<{Vr*f|+4^Mu}&z4CZzX`I}+ z#qcl8E%T1SaZ|N(9d;wlYQDw=mSQZ?FI!2oHRhK33@j8;jt7v>Z3JO;kK-?IUf=Q^ zwOlPV=W2Ss<6h%Fnq{2c?{j?(67{vZKtY`T|K+?7D%=FIF z$f_DizxU6|iJgTG-eyf*ZXVS%IRNQb~-P!R`=fYW1y_h*et1IQYQpLG)vGWTo zUtKJJwv(<-EHYOKsGRzXG9lvp!%qKN_tBJyE!$xWHJ$^?tkXxSjgE}g+!LI#o}w*` z9F*-ssQ*HcH@rv9QC)n5A3mc&Kh-79+;I`?LVo`?x5|1i@6*B)t&Mew-SS?X*G(Ww zXT{^requ^a&SJejY|r}jDD3v0>9x5VKNS?-8a(;ca`@y4!^7dDfy%Zv!ny8qK}mWJ z+5$TH#<}}6X^z)liJE_Ciins=jq4Be_=wUIOw~24M54I0`3JdG$@a?c+15ArA5nFi z>?_KJ-zTu6ZY?VgfLzf5sha4nIzQLBexKYByPTc zUL=R;I%9tzk>_O9l!M{1Ho#==7uK)Mtav&V))Cz5{o+!i{kDaLj7dJ=ZWM_H#bq<-&FHM%{}hGhm)Sl@skC~Y4e&rauPF5Qu{y7Y;0s~ zsXyvi_VXrK*9kjprIsi6nD~7oHTjH==wzpG3DCWC+u2!fvz7fFwXz&s6=U*N_69e1 z?Y@;>xX-U(&m+$fCQ#w1DJj+5wbD(_zIPkOJs(r6a#WnEzlW=g6z@-4e$Y>GW9`wB zZ`j!_tK$Vj@VH!w2RxUZe=pzLHxB(HS)6-A^7t_A@n0VpY;ftvR|+MqN!P@jdNezI zqBCNTfXUj6y_cAWH+PfdXicT~bcq5v9vi*ny*}%`_^{rrQjTC8mx*1C8?16;b@}D; zz@wsWFQ3gzFvrHBmyQ!J?|bt(6^rvd8LIWR~0+3md=ldaP!Ko zOG)gBRv9YgcAfquws;AT@<6)KszVQfGX!h4HMIMW3lF#qm$KF?Og2>ad+JuBT$JD1 zm@s^f7YOwxc3zFXVAJ#LUkllMOJClXsK-(5wzyi43?6v&a!*D;*^Aiufw9c0_L_)r z@L8lF1VNs4`1oz1>Rlt9zu!3}c^c_)90wPDC+B)V{W9rgz>hOezf1E|AGV-{|DR)H zV}4<4$GG>obaQx79`%n`u~{{=D+BCOez%$p%2xXC$1}@oYw~32!)vzZKHZ{e)C`Pl zPitOZ7joOZTk_Xbv&9Z?Lg!}cc0wC6UVQCqr_R#k{ZP?z&#fKKBPtVd{h#&=UHC2^ z_XtA}&oOh=Z6ffYAuzC7UnYuqZI*W<#+NldVB-ZtydIj3fZhWmRI_|DUffYEH#RPd zVt|>thj%01af`Fdj>nj^mb7o{pj4J2oF?jL`#}LoH@F_whtjr>%)A)U?iTKk_bqAl zz$)P8i!Ql`7n5qycPTtuAx_bIZTqq*R>GKXrOY%i-s4`6u3WAl3s!y@}DOD_1C{z)-6DqfL zlRXh9GNyOrbD@C2E8Mo5PDQzWbz{Ip*L#aA*kKmAd#+X)a7H^|R1LW{R*0_ps>6M# zM=#%>6-Y|9eHq)1TdHjgSdqzRuhtY03^kxf(pAp}VS6xb!h0Q|Gei7>vENE0eH#_` z@vk;k6g>0Bi@yeJ_2hb|D)|NH2WGa`-3@PlC&Jgi^}gWNmh9YaxZp+PBlaMIp?w|q zJ>l7syp0C+XDrT6Bfl&8es5;0)w$>qK~(M^A*)>~V|Vtu<*rZ{H0m zyEr|zQ@*mEm?g9uK*!ntGmTg7uX6&qVizg6*bgGe_+{TWmjapk<`;T5jVfhmD1+{{ zpVd)|vV9pile>rc7V$vogOR^K&Jr2o29`IBeiy&7Deb=NIDk;~q;yOc?`UW`qs^5T_9KDEvfex&FYS*_(G;-j-2+x z9EJ|pxKS4-^fQn_t~+Up^P4f^C2j$mu62tjp9`8e)pEPRan!y=tJQ>E(OZcU42B8B zOU*yYejjPuURbWSL4I-F@`ITC16#9{ z+D9TUSnDrcikyDD^sIIbW!r9^sh$9d`NATN&Rg54TS~J$qDn@b{#> z&A|KZ967T)MGT!D3lf(vA8b6xji9?$*EXa{{rR7|XME=wJ9l)xBTToSrkd-QkA+tDiI^Kpq;%AYK_=}51bu><7~rTx?LU<~Mt?x%lv?kJE;Nm+CaLB3qEP1hcs8+VCj)i>4d z_3@jk_MgbLqCzD&5iYmCjj?@cp}|_dA7K6hU?`^)v1xXO?-ELX2jHQ9{nN_f53Fp>TJ>eU^;EtCBZ{R#V& z5AU1UT_IUtZqC`Vwco6$JKs-znD&0j*V>Pih)rMf9Wcfdh$HeZ4SZm}{a=8_K%A?{6eeAI?5im(wrM;H0 z->mJQ7fnF-fbh3WPNewBkRDJl8eif@o27-E%hIp@jq3$&ymOfO}*-QlWsY=9peUF7i5Zc3^Qm^=n_B7)>U4YO^NwVrD z+({3Ec`zfY?(!~;CTcvNQLdApU)4+!y&RClC$yC~SjTh%F@T&#oZC#ExC7p9Ft=!X zSp2xcg>cr~__%88!X?S_l9>0hIEL(>0>$Dj;{ z+nDv8qYWO_vdUh;X+IL{&H7LygS+sc{nzG15{JXE?eSwY72%)b+vBJHTXEMN)nvBq z6F{(`4vJI>BgLT^K>mX`zGEU_%ip4o&(X(jf=|0-=k5QbG-(1cGz| zAxf1N?#ZlK>)rd-y!GbYxof@ekDRrVbM|S!z0Yr-eZCX%u(!9EE<6IuYE}7&xS6HT zax@ya^2;kFO9tw(DjOE%!CyI6RxWv@R#IQ%j=z;j+Q&E+0^O>69VO7;q@H~FXM*CE zzlFtAxtFll=A`l zEyce?H0wJHpn%0JF9btR#V1fUmwj~#*1E~74tR~RNMW&Z{YrJMfe~I3rv8ZwN{kYT zkLF9Mqw4ey6 z71HvVcc<8$|KJ!wD;+u+W3H-N4yM0Tmpiw6-+a7t!Ep1Zw|G6JtxHfrKi!N1=`422 z5Pn}FH&-hKI}~TF4S~ED$4< zxJ~7TriUJ_slQgh!wICfQF>HTd;&4EbQm>oCAK>@TXtDRpnC*+__MtW3+t!etmvJ# z^zvPs&TiutgGm0&FDJ*QZzf+HMWn~I-Y~Gq2~2EDzO)@2#&hrO28q2O-NW`hy4lbwW4Bm>@cqfcUVt-9fc z^5&E8*;PN!1kh@JJYi#fAClj@t-`0839t(uoert^HOqmKZ9g0h~O+qvSzg%#Yp_lhmf`&@m%6FA*Y-rj# zM;D*`nG^;!KzcRb!8ZzmuUg&!a7=r%)`RJFbQ zGVTCjWpE-6#VhMHl}dh=s?2owqlq$f##~sO)|iHO(Q*ju1yzRO+#nqpcgj||c`?U% z$N=M{8kKL$;luWZ_o79A?)5R26C6N^b#z46BOE$ep{f0o&#k7J&;R`9Aj9GDaXAQi zV4z0wKYm;KYI3b(MUw?)aDULP>T1@J170Ql1#>Ri)@js2uBV$!L_vWU-HlRt-r3oP z@;_BIw{4-44z*Po-P`R5^sUvl5MZl(7ePuqMd46e2W$rb9W>S9^lIh^}C-4W2 zufFz9>lS9;+c~Db`U&#_*UKJh;2mHb~ciT z41i~&*qN@(9f)8gm3CNrwJW~@&E$jE>cow?|%(BeWQN- z@e!7qePRkb483Tt1qOf>41k0`waTudS4gT5I*k4hLnuApXrZcV&*1MjiRK+n>S4D{ znaivR_snjc@;T{Yg#9$*b&u&itc@pBVnM&4q`QcBPO)+8_Ws;i^{|I{epYB`IUvGS z9CL9QZl=W_dWufPP+vtA(RQtHkCDI*?&>K&cQ%gSjg^095&u zAhWv&CuafyR|DE(?kbjugWKJx7MI}RhW15(S=Vljs@xS_rpQRLJ}B)on%%cL4a#}z zXpBKZZ>L=9EgLas0u;8uW{KlExpB18xeYxp-(zh$cRlKZn29m9-Y-MmDg@G~32=sf zu*~1#`j_~XZhKs166+GF3|ZS;P@+}x^#3A3*wrQuaO}l0d77y4+p1n|nHZ9k z9H{kAz?_QW?;2Pw3y$Ni<*J6i#}?IE5%~~awE2NbvmpL-8jjVC6CzBIGBn1RNJF@$ z1OOaOEUDT$l1BuTIi9Yb5_GDN#XD&hvGWC?`f_f)+Mp3j@7oic3e3+Qx*@~>rk5VJ zVQgguWt*YLFJHv=K%zOtiYT8PuRXSX9rMiAs^|bonEgFF@Cmb}Y-e*26_StUWA1t8 zbG$Urd_Ie>P^h3%`evo1c}*WsCp>?$xZtA2MD;{?G2hUQ*6c{Z4`x!T?l*?}B(vO4kkx{h;~wyetq z3A>MhvO`@=;*taJo&&cA97u#rFtxPc=K7I?x>IGr!6TJvKS7=$hYY&X?t2-H5_4yN zNX1p-eNV@X-OGpI+mspZmG5ii)%@DOI zo{ck#KH8j|a)i@T?k$@25z)sQEuQqs3Z}~hxy;Tv&S-DaH>)Qn%Lh=))vuL>M%yO6 zWEMYdNEKQ-4O*zH^JnDf7kE6i^@uzzD1~k%SYq9)(1Seq{o(pCKkuP98SFYIYP(!Q0+V0q{#ZgReCokD!NQ*)p`oX^ z99JG7k|t-Za@)1#KLw;^=qLu7Myv4j>%=n~S`^Sw@-~Yi-o6D{##&K7x-18J=GnPy zG%SGZa01vW zow!v0hfH3jkx1VBug!KoP|pd0z>gWqX$x#!t}`>@aN-XSv{Pxw;#1^$1ikI}*2YHO z=ts^-{``f07wutmWzw-lKJR8v3_xtaS4{v5VUbgd-6sSKht41*kRD;N4}}KC(HSZW zLO)Uu&BjWp*kVc3V4`gI0zBKI@@m2l!P;y?3pDLlQ5(x9i-%H-9LpSIX%yd<>!XiM zOmQ0Zv_(ultLhRnM6h^y$1LGn$FsZ5Yv)jL^1&7Z!4oS5g=(tD=IY0qJYf<9Tj?(=kocRT>-yxiz>Vvq)&)W4#-7Y%MOljIH&KlJbJ_j5 z)OB|IY>q>be9orqAU1NLzc)$NwsT>sZh8N)8|lQJPA?D9_18WsZrEIbrHAZhD$dhj zJtvAN&-dDWwn4>+#Y6d$Yrl>vh6I_??=gSZ0L+j7Dz^`tAz0w*Xgj&-QS>F-zNV+8 z4~164*duUHuSutt5r!R2+duUjHg}B}?U}&Lvu^h1R`?W}*EEM&nlJ%Nz2xmZ|KodJ z%1b@ljQa1021=rbg3@r^Im)rKM(J`>Qrhu^JiYWw5`;l}({_!!nTWJXIV#D~ zSo}UOUmf9f5jTE`sf-H*iOPLlU*@Lr$!~S<`LD#gf{0J%pq%S*PY055?z`nX2ENoX z1>f!15!YMouLu*Zxb@*gNBy+EN>@3A!vb9Hgg$6TUnN!19fWmc@(L}F@22eUpuU|c zt)1FPawwyEfB87RRDE+TNGob2^(&Q}Tji=8Oo?m4dyg+BmL}QyAx!uBa`54%DT)v< zs4o5`u)WI?4e!~$W>?TryN)a2=cWQpJgFTa`}Dk&&qe71m?LPO**adS(^WwVA(-jF zfO7Z@xw*@7_wkaI{hhG@a`H-bpj%0$TlCnKBM;yJ9xm#!C^6zWCfCuNhAp)fzWwPb zx#8`z|*0$@zIn*1cw@Z}10uzs`+sVmBRLb`@1aG)1sv-T63?a9{ zE`qio?Z_4?JOj5_+sX@scy{F=EUFsq7Mxz2P1GeP@A=hJQQI%O6V{_f00}lRF}b!B zJ=&jD5^&O<+|@D(p;>f~STAnXJye(dx$35iD~Zm3agh5c}i*Bcs&+?_1@`CF|+bjY?btUH$+Vic{i zOkPcI@+K0pVI)}poUH1Xkl_8DFWn(>ZtiUp+v_27QA_j|)=5#+_j_P_GEhk=;Jao< zMoH2|Ynws6iYp;j@P;K=kb%mN#~}3SyCcC)qVJ8=4tJ*mCJo{PYx6YX-sTRW5cA{g zcLgP-k-Aa1`6NfU^G?J$y?Gs5?ba=D)ygqM2D0M#AmfWaJg8%fSR`S# zi$33*$0rk?Q8LppR*c1o8NWle4AckXX!Q>Ui%*~q80S*b16Np;k9}~B#igFuiF)N=T3~g|VhAz$vDB+Vzb~^M_!4UM3>;@r^C%UU zu(r$2L3rJ7E2y=4qkogOXdGB$Vd=B--p#qcH*2JU47>c>Qrb0T_gGQKCMJ33gdAND z^%u$1{p?Q+FPJHPh zwYoaWf}vYUKpKqJvGUI}TUwm5ZYL7h5`1zCe&GV06bR(R38w!wiFp=*V`k*y zY5qTy2^x@Ml=*xL=m_X^D4_QLxkNZM8h3ypFzMr8Zqi@q=^t5Qp8Xq*7q%KW!)VcG z?!Vq);pr?S6qHfTx$yfmumCjz&)-dizrI^$$Wd+mrwR@*2I}BJMs@$;8lBynu3u+odTVh3Iangia-Ua zU4_v`a90utwrEoXMJ;Lj<$zfEHO*%biljC=@3xqsi0a+< z26Ke`^A_*HJ?F3|zgIZ5uF4^(!;; z48(~|H~<@#W_KN^qqA8I(fi6NYjNp`dhkxO%n%EkXWc4}HM*bzB?g_%;x+BuilqYw z{g@>#s5yB|YWy0oW>-&6xu)hcJ&nC*O~k_q2F6`e-;Obo)J3fATxY_ny-7lq*i_oT z8edudJrd>IZGWY8EBnqaz(GEJSaxp#cs|I*9+jEbn=C-*+E~G^5WwTtT)C&R7KQ_> z0y|f*lJ~Lvdt0Lb-MuRO48Y;hD+IiAD?5VnA*z5t1q~st-c^rYI7Q~cZd2V-Pse`g zx~y`Bt$XA`w|Y6~D80XcokuJ}+g>@;MBgjoDFj5g-+!-$W9_Hr$VSWOtohvC)%Yh1knG6M9Otyf0wi#*O1qgbLyznROde@rug#rThCGqjdJUu znwiyrHJwX@lSh710iPG2*UqFiK-GFVe@)Z6G&K%xqBdaf;SburnJPcAlr7l4#^oN@ z)SP@}Byem!Ya$*UV{Q)9bVr^2#&{Ha&zfs-YLc6Gl;x($WV74|tC_F*9|6rSc-luK zqJlND!a>}b3_~AP`m_GLIcgy3Bo5xhJtE*5x6PmBLT-vNkN598Ec R{;>c6002ovPDHLkV1lfhFrxqf delta 888 zcmV-;1Bd*f1)v9zHGczDNklxNQkBc$s&GCj8Uv& zD~6U<>>?_`Pbh_g7P=@d)YcXX-L{MFM7mW`#9~T!VcJ@1YZJ7wQD-nljgrhX2~H;C zB<~#;GeRxo-S<)?(DrZ_@A1x@AOAb&{NJ6CwWpN%h_JZx!hfAdu=&p^79YK3gxYdM z?3vRJqmw?tHpQD8Il7hdVqfKe!7+|^GdN~RGseYZEh**WZ2_pjvMNq=c-71=i{j`~ zN+JQM`Dm)4ri^6TE5`zMW&%_55mLM>hmGsiUA=dCoRx}_VALDKBq!8(<`{ADSdS<9 z=)4Z40#F}}@qgJx^)4$evh{OcgIrcbXm$z5-c2*}c_JV$$E1RA5QA0J+ z27ur0@aZ|7%Hh4q1@PRQt8xM3&x~U4D^ZP?tBWIi z*d^#71%f#L|Yi+Gsy%CR)p{YP^FKgp3ySze14=en8TYJXtx>2KAUv6+ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png index a3fc65a5411218ff7663f21fd9806891ab30bf0e..d25093ebd7944d352bc45cc6dc8f8ee471fe59b0 100644 GIT binary patch delta 842 zcmV-Q1GW5~3-Sh#HGcyqNklEY`_?C1e+`re9R`aVX~0=w-Lk@q<>DE(8kCDv!;zvkV2c~ zVT}GK=xwpE?Lcg}#;gpypGWOGjDFA(Zh~!*0 zT(VQZzyJ`U;A#fBr?`7eEKnha&?_rR4)KzRkadVe8HAnQ6(C{b>{H4qelU7p>BWE9%iWAndf3t7(ygUHz~n zP?X5wvU4NCR{562op5$&J`wBtP~RsIX;ERTMec*#>Rh)1bRY#y+fz z^u7P`4RuCyTE`nAPH zL^ywfPNuEcjEO)&o`B0A=TX+Z2A&I$HGc(DNklqI6^dkGnK;ZQhzen=FpF6+~Cs{`9*X)Vf zme%v>*l=uOF@JPyB)aQL54zcn*Raw(0B4&vv<*WIJau-dAFHh-28yq00Qi&k?y;{} zwd-$E|EAI#oT)UiduTRr{K>{5b-o5Q==1;db#@$6x&We(g}Y3iiH zrtpyR8CDWAcuU6(=baa2LIS`oC*{BPi+;{s1GAg)Jv1oj@uPYBC5ay%$R0V>=gs$3 z=Amb#@a8HR77@taa#DU-JIFLAJEnX(H-m!bB!AT2SJKi>UY14=x10H;8lTV1=w>Xv zcn+^F5$W#->8Yfr>bZuak(~!sty3F7J2cHnW9`y^5}Q*}JcVl{D*XP((<>KQxza|) zvMAdGw9ef2E1N?@GwTkQP(h3(DQOww_4mD!hb>$;nTzF^cjFPD`ej6KpmgKrtKo+T4+Z*!qd?_hcKvX2vHSF4>fF3X1-840^ zc9Tk-$hM8L`$KiL5|0OgY`b*i@qpMN24 zSHIpoZaO`HbaW_>D^Qq|CG`!QIH~eqk?2U}(*Iqg@6!Mr+-Cz?+bH^6`Fjw>CIiK?C9<`^RxU=U1qb_tqvDHgQ_a>7BV6hR~tZA z7d16^=hlCcz0gn06qk#N3kpz5&CHVF(aJw(9cayc+Sdb+zfK||cyx+XRpYpY82G%5 zP3GP0HlVAU5B3^uZ2*Qh@bwS$c&41MP{)reAU>X1GbLl1l$GmQE+jLfP;+45>Go|} zTWN2@>7=BDFOMpp4}to6T3Tpmz~P{_mK)bm1_G{jT3fkz5yOW-XD8)ls>6dohlj>S zu3ZgeHPXwgP2DXYs7(GFf3W(0#2)_z1u67H_ZS*IgdQsW1)^MyIKZ-*MF0Q*07*qo IM6N<$f>T+)M*si- diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png index c365538aad27a64930d58988cc3f2012739e1dcc..c9cb394dddbff2cab55786808072ccf51b1c5b6e 100644 GIT binary patch delta 857 zcmV-f1E&1G3V{cZHGcy(Nklz2$9?~I&pG!|**yW{$V^6}H6Cu_ecfcYT$#^HI#9%v@P;H8PcvH)Zf&nHWt-h*S*HED(JsViaB{3isI%Y zG4)l=RR5x8sB>YHb#WEf`o!m1l^?9`xE2KsRY|tlUw`Q86+af#Z!YqCHH(7qmI|iM zg?t+T%zVR*2V!MS(?%$Hb?d9K8p888$yr_eJj;&BNHMo#l%&OqmBav#L)ArV_oKC~#PucpB<+hs4Z%0gt6bHI z8ZWWkP;(UR4YZ>TUJFB{Hj@Cp&tb?bCO>c^gMXSX@^EY2Xm6D8&V|68e&HMEjpEh2 zj$E{ylH*SzoUf-GtDfb#s|1;uQfL9dYQdoc%<`O-)tq+8pT5)1>(zKW!%9$cYq_+{ z+fR6Acsq@_yn-{jj_HqF?{W@N0s(kNq~Sgu7QtoSDGobL40yy!5OpWW=}-?k{}aPc z#ZuTSertKQBd#uFRu;?|Y6iyQh(y%I)KKC?Lr{B4tEE>n*y*{kaMOQ&bW|EwPo@93 j>nX$Grp=(A42Js$nbP?fhX&K400000NkvXXu0mjf_Dh~w delta 1306 zcmV+#1?BpI2fhlBHGc&2NklL z4B??9CW6H%iXrM3L-Y$F0Z|hliN+6pQKNuHB}NFPybNFwh}eoitUN@CQn9uorS`FH zciY{2{IE+~G{MgHHcbitoNRV(X6F2I_RO5QvodwFGIV6iP=BHwdbrMDBbaE&MR$nd z-b#jnQ#80K36$hAW(1Z*<1!H9{AH>x;h4KN#+^Kd*Ori+VC?B%I#tWMeYAI)jk0j) zyCui*>S7mIfx;}-6dSk>n6j z-9_F#q(#wX{pWcU zdJ16K3ErttRidbgHUoUb5n)A91We)m3f=Z?^s^245r1c%hp}k@T_N7srH9W&6Vz_G zjC)5aBNF(jF47d?kzxUSeqP$9Km67&R!sNZ8{5P&yizLljXh81c*q$=qmQ*8>**R7 zT)1{TFC54Eho!6vUt1)EB9zXOOIO(Nss7d6-?)b5PBP3dV9iqL3{qaH-4o84gujy) zw`xnfJAbUWf?Ha`qjN=s?O)^TMP^&9+oGL8*I4Q2yP9S3%o15V4WKLFy0JFTKP1JIBF){E*M)X~6eVs_BI_R$&z+H`zmwg^)Rkp$V?|Td(nqp!m1Ori zsyDV!bPv z0Jen@fLStJtaWp*Ql;kiflP)Y$7ySK4foh7np=$DgbjMPw({18+TnMB_2(KkZCB$n z#S`iRPh^O>H0ovQyvtPqOkZYgeFSd<$ifq}cSAQCG zhF8k>8v8a*2B0%QOEZ7e>3t=Vo{Bi2h>$caVm0T)-H*;@{tIF6M zX51^|^N6vitwj`dh>d0LYyljH%6}^D>WYAN$f~S9b0+TsZ{= z8&Udh!-kVPPR8en9YgspPM%PdK!BWV$;*|y#!6PER9CCN0~9C3=+PWKiW4Fsp8ScF zm1)2aN>dYASyWXTxJHr3SDN3l4xVoIsy4W~^^f~NYRKW0j6KDFj@dp7c5K^z^05yP9kd- zNiJct!4N|u1Vh4R5!XWbLaK|o^F~RVQweEK|IEHFx_^)~>qg6jK`FMcQV%Z{G4`*x z6h(Oy<#C{!_kY{O9-x>Oo?43CA(+bJ_wvi1e0EaCnoB|KM|K7qmO9;eY+I=^Qw5w? z;6fAoQL%^=x5GgoBb5~k1e{0XQkvM0!rb_@po?>ri2pKg%DJ-yjLZOU1|t5;RL8&- z0V6ZO7eK^+nJApQL~((Do$KYt-?WJTGS#uIFAjaU)_=71B~o=9xIpI_r=#$B55Eoz zIIlEfz5Y8#^!eYVjQux#7x4m zjF#o$uK8r9$7IBv#HI&TdP#oRAfY(E1N%-@?Pk+3=SNj6?s=&hO>ZlYOA5v?|_^3&b zjz!*GyD}_qkg0QK#9Y%KCet}Rn3ZVoo<1INIIreGQz$_Q^JJ2t6;8~}fS#;+sih`gCp zlz*_hPB(Qs;ZFokXd!()@N?-!pXO0)%)%W-P!bwDn;#G)`Klbz42 z+<7wArorXnfl>-`)sAMk_;&0EV#KA2b7WbEsdw+w} zs*J=6*Rp%!&<80+X_3qdolITGb1)aCa#M~{67%vhQOJmG4Q9rqr^+-SGW~+jFJNQ_ z{NaFG67~=31gehhT{t#JIKkx+j(;Pye?elWoX^LhBTma<|EJ+WdhCZ- z@7UNxwwH=>8Eey{HFb@;wL@&4LVs)ru-4Qb(5HIU>I${sR{5>q7QrBWXY}DO=pB%h zp4zZgbSD4+E*I%(;#L6yqf$@p8f=+m35_!{B5&3{5^l9NE|@4lV(#K_9`jE^Fj}xM zbpcEiEKEj#(Sn810vIh=n0^6FMp&4P0HXy9qXjTgurL_`Mhg~y1B=WY>JT70#ex6; N002ovPDHLkV1izUaTovq delta 2039 zcmV>RvqW1PKlxrN^xr{rHIIc3&nGj14>#xK6aU4xB~eLP+~!5`l-#^yqa{_whZ zW^w#n0Bo4fjDJxEEVmDrK=j9=JaUG{&joH-_1hcI@i87-I@ z9dok`klX>rXHj{@0FMDLxP+7e228mFqz=HyTQsm_z;nB(s57Po12p@~nKnA!(dqdQ`JlnfV#g&)^nb^pe{#0gh&{IoRbKd;8Zll> ztrt{zYNu8;#mfcov;A6LZDz3`;J#=64~$sW(anMPN%G3bOa{atl)4F@pC9klH_A=&E6hdt?-`l3O&BS6VN<=R#x{qP$VI>u{JpN~G?TCpBNWgtZ zkBe0^!Ww(x0zcZWT|KdUXrgIhQGS>Z_MhhGd(>~mN(DAtu_XVWNJ--9Rg#($u`*{y!VlUN_<2Q6d_=IK>Z)j)f%et*6&pR9BMOZnAa-CG)43EV`yy^x*3 zGY`wK44&Vv5s&t`SXnGUR}as=swdCK$pyrDbj%&jQ)?tGg}2Udxjyph{CtL`)7Ht8 zujtv!@w19}YN05X?>;0+UM!2PCB1~;X5}oYYvzfUwYnjGDhND1K3Z5Xg>Nnsw+o=8 zynk;YT<>Pn7Hzy54|^gI4=t>nE30P*&Mk|rha!alT)oD3ey^Qf@m7J2*m}X`;?bq@ z;M8Er;WO0K_tDf7XMz540T9C&ILXV#l`<_iSYnR|v3uejBUV~SO=ifz zuJqdr{|Llnbj-@&sr52EJX>X1Y+7ud)h$%?tg1{pJ1nSaGI zBqi>7%B95&^q9)Zm|!WI7;hHUf9w~K}*Gg>e+F1%M^z?9pC-{SqsL>e9U9Z?gXLEIjWoT9DGkQa;? zbg71&2aPb@DwXyR149z0Hu(S)7hn6mN`m6zf%LYNg?!sRLZ1qqk4?iV;(0E1fC zutug$!49td7WIw1S)wJ!gB2c_!KyEaH)PYh^E%JJpqFcz|9Ms}lirid-+$lXrL6(` zJRUZFT_)zT_aIwdHT>#~{Uzb-V%#n&D(Uf&I+&bMU3#)KP@#CLq<@O5?OeYe zDCy{=rw0MckEOT;ua^N{d_KCm0ersTC^$8rNqGQ^=dr(p`lvsh3Wc#AVQ1fd{ReGm z0@$!t=FFntK6(3H4Xv>CHGeHR7Ive+p`#o+s=)FG`SNl(b(&wks5h_Lm{};m>2mTX zF|&YO`^=ba!-cAuBY?oqhDHTCIzlU`ef=YOY>vqCxmaf*!byYZE4lyWSKXIWs7A(p8W7xD>U3Za!fB&2F~jnuzX>wz`(hN zX<<+blP8EOg9lMmAb-9dfa!&@b9bV8p}JZd8Ud}W#gk$h14mU3h>68 zK@rWLD|xvTO_kSnn6cj2dO;Nt=FXAMPSVpTxL-u5x=dYN;D3eN{bE+JjLiw$($&qb z-P+zBNhKi~Nls?M1W{$r9&NqxnTyv$Uak}t$aYg+Fr|fmUnFM?OBV<1w=Bxabj!=C zHv;#I7nv}gyj=2fgPwo)lvbP%k~B8~G==OY4#~jnqO43?TZ7d~OSQdS($dUqIs&q8 zo&V!&-2&WhhHwlC{%mf>*DHONnMr8=s=J%ewgDoH97#=0*uB}=w6xIK8CG-XP*iDb zG-5ll{t+`?_`ik|S%VW<+i-^DD}T0uZc$-^OvE5Hk?3?7 zn3C{djHC+xaBnwB{n=XdYwz4w#*a(`LRJ>PxL{oUX1{K&VTVSgnwvX)*83Wn7LTh3w2 z3ARhxcMt@(m;}FoYj$95=w#0CNA-QUoz;mYodI6x^1K+)(Y{6oOYwk9}-bo(1 zhlB}B-(j_L@_%`pFk$J!V>l-I`AiAlU}3`2lI|Wbbm7uHAf2?NC4IIRe??GUsz}hZ zT>cXmuRu6wNN4GE8f^j2=x_6A8f^h)S+)vhX=42F3x9SW;Tt15s!gQO`XyibO`0mP zqTSN>KjA_@*Dc551>|wxW)PPXs~?##;w?Ff4s_Cn=!qVMA@c+Rz~0ImFka%G9E88UW>9)UV7L>FmRv!yF$~{GdW{ z*k4O7Eq{A^{5(GVn$u${y&b0@1eba7?5gbT(KGnu7&EI^3;B0=M%SET>~10l<&XQH zp0GEQ!QOWMIKZ6ID+l8oUN{e}P4rzCb8exQc~QR#08(juaD;;wbTM`kg9IIjy!#pV z|877o`a8VVhixyA(~0;f+D~)d<2KgO&^5H~=c~iIdpnN4f*aOiQ~iXHjv!*O998Zj zESQTOdpVvkNG>^id;5(3D(d2}OGC>hdSXR(Eyh#K4ang*2)FRH|w%``W+ z-IGkA<7A=O3x$FoS&HqilKZBvt>(lwKaJh)0aiSLz5xz)BBrRLLNB%`I81x;QM}$r zno}NWY9dj5bdWoLRuD=-Ps>+>H(u6JEq{R066|QEW%n!SX-RiWE)84!1ws%Xu8qA-gM$#>*T8QP<=n08}NChujZ0uoLH7@pwB1pf(UXSSKGsRO(U{S_W zhvUYsC}Oev?et8M&eEaFhAt^WeLTx259iJ!5=Ci=B0)7^qS(zZZ6r;%eo-=sPY-aHivFG^EfD~~Hz&~5!_U-GRVCCe zxl(ELU%`nWUkj@VNR6Av)I|E9P!J~%Mp7t;X@+4vhb<@AE@_ivJ;8QKn;hF=vtvEM mc1fEY>j}0?+T>VH@IP3$g6#mX^owifEjMLRxT1c_bj-?&6fK@~= zMFVj`mVyF_#zZ91)O`bY6JtV%QGy8=6V`JN#)*|Hl2QXnatdG-?`_T@4WlYJ@--WW=1eUMZv{42!9D9*a!!m!a+xPETl&{ zf{*Z6NRM*JR5&RTDN)d>r5R*KOBbrm=;@bcQ;x#v20S+tlj01ipF*bD*saHJbh-6Uj0G^7(EC9xSAk|ISzT2$eFnH7! z@{S5Q*65xOWMrr=M2BC0)|HWnSLTsVW1kK07xRH?QODenygY2PT>rHTOulR_-B`1e zkJZBWgaAfL0U$<)f?K^>S}k~aJD+WqJkUrf02EH6EARiBOL%n~H+RU?)`1a0I7Wwp zY|qaV^?!I{J9k*+vxRsr{r{F`z?~Tbv@f3Sk+<|DUigwby8Pus;1pIX@%#hS(2A1d zUPH#{Fn5ac`>slS`YSX0MOy~~G->IySi6|U$798M_8D=}4AQ8;z?S{|)qcNw5(tI$ z5qNtkrN&`f2|E7uQZGh_xjC@e@$rxROO+pmb$`R}gi~Vi)>1M=!rF`P5BVm;i|!zs z9q(-CBc}tw7XA)t<2by&gu=A|P<$Be-f4+G0=G}Zi<|hLMnC%C13xO9pNXP}NUZ_@ zYY%po`O2$S;^ocUbU8rUI^ZwD^QYm7xkN51KOBbTUteO)tpEbb7XAonA}qNZg|j?F zx_^7|U77FXJY0~huu6$%9;Umzv*O}%bXWqd`sO&i{RBnoBzQ9*3{iOMK}t)+hJSA*Fcw`csT6p=(2s20D7YPWPm!@=6D$Je z8}EN6;X36ofOL3q=!gH7&SuG*8G{oT12WV;*luB`9zA(39=dZl?ket+#7CtR)Gc8mLgB~h{ zEPDW8>4Ldc=HfwS0l;cSZJji0e!_^@Y6&EQkFa=1uUJIWO;9KRpt}cWYq0fOZgf4X zjFa%>G71Ut^soL0AAZVFkyu+qI)CrC_?WR}C;xHK`E&N2cx(ZkK8N?#vrW?bQ4$oM zZbF0(`%1x#lw?fJMsgw*tzpMZlaom?(Wq-cTf4LW`3oRMUk_^Q5UNFl4k^iK@4$t6 zFwoxa%%zk{ESyJbH72FQl!b~vMg4R@0%1ojcJtS4vq7Q2x_2o)7ST80a(^4(O16L4 z%_ZemTmo3TuxO-*Lh*Q4q$o>xFQMk=q6;HzjZ?e;P( z6dE-w7R1G3egTDsxHe{4|Jslt?H-<-L3SJT zdd$0@0Gv7F#W3R*s8!CJx~7)To%5V>-|FuE#>u4BqWlnVaLwDMT&!M2x0z5@ChDhz zkZx%~csQPW*6F#;j(#W87(>o03R;89~dJi>5H);Ydkw?u_>Ka$_SbS8RL}VZsDxG@ja5b2FRGUL7(s z>HK-_bSgxra1+922uKWi lJ<1V$gvUa9lq2W}{|7V0sh@gEj*kEU002ovPDHLkV1n8*b~gY3 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png index 5f8659a82e799c9b4b92d2e1cceb8627c0bec932..da0fba9728ab9f5d6ac740ae74ef4567b7010e37 100644 GIT binary patch delta 1787 zcmZ8ieK-?}8mC4pZ0=+YX-z`Dj*qj+q8Qo{`OY`#nK7KWz9*cawS1on^DS0c!!X~3 z%_OIXV%?okgm{>I4tJ8yT+X@oKF_`HKkxhge!us>_x-(3p=F>4NRl+q*4omIShiZ0 zXkQQu8)UQl5g?BoJ2L1(guUKR&O(fuA9Z%Bq~DazU{?FIb>dqSO2eAB!DCG&^1bE_D-)=6wbdney50QX=wp+B&Eo$l->O;p<>5;a5{JqOq?#<+3f*OZ~ZZK zv!E%pa(1vLroyI?gwgZ{)EadG9D&|EjAyn!CoipD_TK1w1o<#!LNSRg@?F0p$Za4* zODwR8X$52w5G}MnsdhB^Q21{OyH5UJeGFcUY?!$Q$rcy@()ZpcYih5}g!jbw0zD`r z)@Q>Ej37KD$y#5q&+<6e4)c+j>Cimomg7IAt`Oo2S@_kx&7zpF>Y5C`x$^8>A(_j_ zz5&UoKm*q4rS)inTHU10-VZ;$e6%63JWhA=7Zg^WhQ-rWM-&k6@#OoJ~ z7nCH@>WT`hI`S$dWPo*#ANI(@Lt@b8Mp}{?tZrM9gm6a|D>AGt@-gf-NkmMP#vkq4 z`ZTT6J*qmH5YQm|giBmsSEO+{<;O(&9Z5UuhK=3_b)1dI?`JjAOsp4K9x_@brWTB; zW02%OP%p5}B$lJEC^d52YGCDFU<7utqHhYlMi-nkQPt|=PUpG<-aFn_MZr<6&)J`> zNY@=cscWTzWKvWIBfG87zMX4qmM5j>=lJn5Q#6v=%5zWJnK|AcPlklWKeUiOiKvW! z<&uhxLd}jQKeYl*O$^X zxF#<`f+g)@;$a5Co-UL}Mz8knf1E*(cyc*kzXzkW*KqDjDZ}Is4~jpFR;JLpBh}6O zK&U`c+~%^xyrs=w_{L{m@@&cw_rAVdHw$5VTPTF~likMX^&eP)3O6nW%J5GdfW~-o znCaurG{p%w*WhewsSs}SB5#Yg<=I-UtUMsYhwcjnulxOe+ik~rvP;vmN&G0|UISgQ z1Tpq(on-h3MGD8=Kw;B2rp|b`XU%_8KO>Y}1~vwWtqZozHXZU_ymRKifPzN>%Yq)Qoa;QBr0w? zbe7*P;n%R76OIj`TrLEYMz(|o5qQ|%kJO$gUD%@>s&5pk%bk!eH-~Nco-~J|hF5O- zEWC>{)>uu`G|R#_ySV`*#%mvtKCBkxz4M+=5-Pj@td7lbI({irNwIuV20r41u+(FR zCj(6P6C`V!8RA~0*2(N8vpd@sNgHh^h@+31J=FG}CdNCsR-s7q(12A?)HU!ZRT0Y0F&gyXA4^x?TEa|@l_8r-eTnv&W#0FO15~(}xwfypiesw2D4sOFo=X1AN zp_@9ZzNna#CaEaJ+Ae92p-9`Lfz+~C03g7hdW{616c^YgKjas-tLZjy7N(bqW`%yk*BfuKwbM#+BnPihFYu1RtD`*p(DbAfTODL$NIw8#@-QCqnR52#0|$ZmGIYv#mSW3Tw$&4 zWBEgm*RlH3;Lqrh!~b&p+o~ACvOGq|`#;{#{O>b3pai^2|EKp;I#H#lclbcQo{BCf Uit+o=xe^CqYvW*Dj|oWo7okOH%m4rY delta 2828 zcmV+n3-k1x4x|>4HGc}(NkldvH|M9ml`F-A%%7ASB@(9wribBq2f|1jR=? zn2J_04T{tHU>t4BAK>_?OBk0|`hr zn`Ac+lFja~f9z(H-Runk_x$#<+|S%UcK4op&m-UaJHKamc~&LUV~ zG_(j7igpPu5DY~NVTyJEE)wM#l{IjiZ587)lRfJb5irtkG`QaF|Va{2HWM)wY1|FcF~iSbQ-qO^bGmYBSo3-{h%n z0pFoq(Z#vZZp$-qV-Dg>$`!5Fy&GY{+{p^>9}#Tv$bX%Ls$*(%Q9EFnmWnvD(i|c~ z<~Ze9sP&_hM<~2`1WEDrhbmFKl;!rQynE#6?uF_lJ++E!C|YF(#~op+xydOZXE4keg=+vLi0QB;VEMvOIe&i&PrS)?S2Qg~fM|~#hDXj+{Pvomn(L*0aXrSABA3V8d2^`!oOLx~@DwY>h|JBC2n~AOFowf+6MShK^?MW=zJ~+bB7){};OxPK)S!9e+1piIs(60zXQ2a8GYE3Z#Z;nhui*t8Sp{7lt|I zaA9kurtf?{61C1S&VmPSrYmy7pDo?NZuJqKl0-+*BdvJicFIlV2yjHq;m_{29$K9A+J_RVR=zlEO~CJq^w`Qiimt+} zizG5qzPrKF0*pOPRB-LAQMuRkNDLNScZ-X*AFADM2bCXwO&WZX{EQ1810?PJv_Ch zGoDfJM2YySnkW*L4@$8DP_P96BnIk;Ddr)d#TQEj_mN&abfXeF-^}z!2O= z`KS2N&iAaK31iNjSb(l>lzzY;ej+y?O}DHOn)nA0XF*;L=4aD{u}Fx4OCS_oZ4lU@i&1kjkxn3s*w1A=CH+&I~U z`s3*C36p-}c+9vMaf6EHY)8dDzeARqjNB}kHO5|W7BVw%tO1p^a*mx3 z3DdFa4!R;-$Y^I5UMuCDp9lRoVI2Pa1PyLwA1}VlpH~9Fy?=Mp%;`gAdi(?)dMc=H zmo31(cl*1fS4()yj$sx*GAQ4un}AuF{>+-}IAw?30k0PTY*wtglV$}kBW0$C(XtP> z8_f=XvS!B_DrR?t7Nclsf5*S<7i1#k6_;De@)3Bz|L`aOs}hMethtABvp|Ha3h3as z{e=jGGHv|?rGM`R6|TpPz?&9-e}zl_3ttq&&ll58*8#xh?by7X0Wjcj1WhqdNyD_s z0C38V@neyig4vlka46z+)kL&2^o{xfbar6V|2Q{`03?kHpE%w;4f0<1%b~2`48u| zel#wbHU*O=06k^)g2J3zvzJFmGwdH*cTQ2=3Cs-5+qE z(75&MLw|VoFK3#W30Iv(eLGrJRx=ot)x7L_pY=eU)Vt#nUWXR>d34X6G&Xg(>C>me zY#J&HtpY5{CjhN&`2Hve`2Hwb+W=re9$CyWWKUVn_eVPD@IU^)g@Cx0UyR8kvZk z*|e`#q6tJ_t?2CZ`)BPPc)f%tr(sSuO`3?@9IC1gt?kK^_%ad;y9IrABYQSw&mMF( zANy*5dM0NkI_{m1#zxG^27pQ9gE};SVhp!449OzsE1@9V9&G{U&!adq_o{z$RCe}m zz<;A{u^{juJH!4jgK&00^R^n7Ourao#{fW0EkE}{(7A^mpgB2Yvm!S;%<~j+B<7<7 zS>(?rj|U@0AT<>Q3n^_90CaVsriN)y;+Zq()|-Rw)7gdct=uzc9x9|W97ukj-KeMx zJylVOIXS*G*sHx^Q>2hkw9AF$WB^$8yMNF>tJ{r#ZD5ykK;pj785zjP2u@Q_`hV&?gJ{v)dr7R)NkG)A_ z07KD27>X9cP_z)S?{(+|!*mE?m<}Nf(;I4tJ8yT+X@oKF_`HKkxhge!us>_x-(3p=F>4NRl+q*4omIShiZ0 zXkQQu8)UQl5g?BoJ2L1(guUKR&O(fuA9Z%Bq~DazU{?FIb>dqSO2eAB!DCG&^1bE_D-)=6wbdney50QX=wp+B&Eo$l->O;p<>5;a5{JqOq?#<+3f*OZ~ZZK zv!E%pa(1vLroyI?gwgZ{)EadG9D&|EjAyn!CoipD_TK1w1o<#!LNSRg@?F0p$Za4* zODwR8X$52w5G}MnsdhB^Q21{OyH5UJeGFcUY?!$Q$rcy@()ZpcYih5}g!jbw0zD`r z)@Q>Ej37KD$y#5q&+<6e4)c+j>Cimomg7IAt`Oo2S@_kx&7zpF>Y5C`x$^8>A(_j_ zz5&UoKm*q4rS)inTHU10-VZ;$e6%63JWhA=7Zg^WhQ-rWM-&k6@#OoJ~ z7nCH@>WT`hI`S$dWPo*#ANI(@Lt@b8Mp}{?tZrM9gm6a|D>AGt@-gf-NkmMP#vkq4 z`ZTT6J*qmH5YQm|giBmsSEO+{<;O(&9Z5UuhK=3_b)1dI?`JjAOsp4K9x_@brWTB; zW02%OP%p5}B$lJEC^d52YGCDFU<7utqHhYlMi-nkQPt|=PUpG<-aFn_MZr<6&)J`> zNY@=cscWTzWKvWIBfG87zMX4qmM5j>=lJn5Q#6v=%5zWJnK|AcPlklWKeUiOiKvW! z<&uhxLd}jQKeYl*O$^X zxF#<`f+g)@;$a5Co-UL}Mz8knf1E*(cyc*kzXzkW*KqDjDZ}Is4~jpFR;JLpBh}6O zK&U`c+~%^xyrs=w_{L{m@@&cw_rAVdHw$5VTPTF~likMX^&eP)3O6nW%J5GdfW~-o znCaurG{p%w*WhewsSs}SB5#Yg<=I-UtUMsYhwcjnulxOe+ik~rvP;vmN&G0|UISgQ z1Tpq(on-h3MGD8=Kw;B2rp|b`XU%_8KO>Y}1~vwWtqZozHXZU_ymRKifPzN>%Yq)Qoa;QBr0w? zbe7*P;n%R76OIj`TrLEYMz(|o5qQ|%kJO$gUD%@>s&5pk%bk!eH-~Nco-~J|hF5O- zEWC>{)>uu`G|R#_ySV`*#%mvtKCBkxz4M+=5-Pj@td7lbI({irNwIuV20r41u+(FR zCj(6P6C`V!8RA~0*2(N8vpd@sNgHh^h@+31J=FG}CdNCsR-s7q(12A?)HU!ZRT0Y0F&gyXA4^x?TEa|@l_8r-eTnv&W#0FO15~(}xwfypiesw2D4sOFo=X1AN zp_@9ZzNna#CaEaJ+Ae92p-9`Lfz+~C03g7hdW{616c^YgKjas-tLZjy7N(bqW`%yk*BfuKwbM#+BnPihFYu1RtD`*p(DbAfTODL$NIw8#@-QCqnR52#0|$ZmGIYv#mSW3Tw$&4 zWBEgm*RlH3;Lqrh!~b&p+o~ACvOGq|`#;{#{O>b3pai^2|EKp;I#H#lclbcQo{BCf Uit+o=xe^CqYvW*Dj|oWo7okOH%m4rY delta 2828 zcmV+n3-k1x4x|>4HGc}(NkldvH|M9ml`F-A%%7ASB@(9wribBq2f|1jR=? zn2J_04T{tHU>t4BAK>_?OBk0|`hr zn`Ac+lFja~f9z(H-Runk_x$#<+|S%UcK4op&m-UaJHKamc~&LUV~ zG_(j7igpPu5DY~NVTyJEE)wM#l{IjiZ587)lRfJb5irtkG`QaF|Va{2HWM)wY1|FcF~iSbQ-qO^bGmYBSo3-{h%n z0pFoq(Z#vZZp$-qV-Dg>$`!5Fy&GY{+{p^>9}#Tv$bX%Ls$*(%Q9EFnmWnvD(i|c~ z<~Ze9sP&_hM<~2`1WEDrhbmFKl;!rQynE#6?uF_lJ++E!C|YF(#~op+xydOZXE4keg=+vLi0QB;VEMvOIe&i&PrS)?S2Qg~fM|~#hDXj+{Pvomn(L*0aXrSABA3V8d2^`!oOLx~@DwY>h|JBC2n~AOFowf+6MShK^?MW=zJ~+bB7){};OxPK)S!9e+1piIs(60zXQ2a8GYE3Z#Z;nhui*t8Sp{7lt|I zaA9kurtf?{61C1S&VmPSrYmy7pDo?NZuJqKl0-+*BdvJicFIlV2yjHq;m_{29$K9A+J_RVR=zlEO~CJq^w`Qiimt+} zizG5qzPrKF0*pOPRB-LAQMuRkNDLNScZ-X*AFADM2bCXwO&WZX{EQ1810?PJv_Ch zGoDfJM2YySnkW*L4@$8DP_P96BnIk;Ddr)d#TQEj_mN&abfXeF-^}z!2O= z`KS2N&iAaK31iNjSb(l>lzzY;ej+y?O}DHOn)nA0XF*;L=4aD{u}Fx4OCS_oZ4lU@i&1kjkxn3s*w1A=CH+&I~U z`s3*C36p-}c+9vMaf6EHY)8dDzeARqjNB}kHO5|W7BVw%tO1p^a*mx3 z3DdFa4!R;-$Y^I5UMuCDp9lRoVI2Pa1PyLwA1}VlpH~9Fy?=Mp%;`gAdi(?)dMc=H zmo31(cl*1fS4()yj$sx*GAQ4un}AuF{>+-}IAw?30k0PTY*wtglV$}kBW0$C(XtP> z8_f=XvS!B_DrR?t7Nclsf5*S<7i1#k6_;De@)3Bz|L`aOs}hMethtABvp|Ha3h3as z{e=jGGHv|?rGM`R6|TpPz?&9-e}zl_3ttq&&ll58*8#xh?by7X0Wjcj1WhqdNyD_s z0C38V@neyig4vlka46z+)kL&2^o{xfbar6V|2Q{`03?kHpE%w;4f0<1%b~2`48u| zel#wbHU*O=06k^)g2J3zvzJFmGwdH*cTQ2=3Cs-5+qE z(75&MLw|VoFK3#W30Iv(eLGrJRx=ot)x7L_pY=eU)Vt#nUWXR>d34X6G&Xg(>C>me zY#J&HtpY5{CjhN&`2Hve`2Hwb+W=re9$CyWWKUVn_eVPD@IU^)g@Cx0UyR8kvZk z*|e`#q6tJ_t?2CZ`)BPPc)f%tr(sSuO`3?@9IC1gt?kK^_%ad;y9IrABYQSw&mMF( zANy*5dM0NkI_{m1#zxG^27pQ9gE};SVhp!449OzsE1@9V9&G{U&!adq_o{z$RCe}m zz<;A{u^{juJH!4jgK&00^R^n7Ourao#{fW0EkE}{(7A^mpgB2Yvm!S;%<~j+B<7<7 zS>(?rj|U@0AT<>Q3n^_90CaVsriN)y;+Zq()|-Rw)7gdct=uzc9x9|W97ukj-KeMx zJylVOIXS*G*sHx^Q>2hkw9AF$WB^$8yMNF>tJ{r#ZD5ykK;pj785zjP2u@Q_`hV&?gJ{v)dr7R)NkG)A_ z07KD27>X9cP_z)S?{(+|!*mE?m<}Nf(;4^LoxXpYuA;FXx@+9IW$s1ICwSxV&w_=4^+**5me?JT|uDS=Q#JE~wHK20X-NPKd(vy-p5gZDkN!A81Xe$CYuDuSr}k*K z2_htk;8e@OwaJw*fa$pR+~Gk~!bYe7?tfA<_fPSgp!OL8lTv6bo@V5VMGPB8Fxx=1FiFnTL1CBc_HO2@>iEEydl175 z_Lc6M>ZC9nSFkuWJrwt4A-`uI|H^=Jxd;_r9O7Q}4NaZQoP*9Ks*fNC0UU$h*4NB6 zUW^i>xe|8dQ1I%)-1@k>y3}egsDf*%4zRZ>F{&@?9`$Hp5{#5R%B>g{{XN8!zW-XP zCJJshbei4dy-4q<;p}C~W%{C&)k+PCN5tNN(h7BoyM~mNy@>_zdwvq-+omlPn^;iR z27~4$%Ti2ZT_wCo){7S(l83bPgXHBx3GAH;XYLg4l7k-sdhcV$jNS@3@0+RR>bt1I zVi*Fx+=_MemxTBPZZ<~;lgl2HI4maGpNt1SVLZ7lP_uMgba5^zr~w^Loq}IVk7Ex^ z;_3*eK4H9bhBmN5wRT+G1{1@n&PaA37f#CzohMQGD?0UgDozKg&NnK%&X?5iCRx^Cbx3g!u*(Zm? z*M2fGFdi83oG~70C`^NV&E3pe!Wx+)YHt(`{<(so;8@M3zwh0IR}`-T5>MXPvwFA# zLCQKqAn2ABn-SL>^G`Oh!#N%g~{;_92T;iHwNLj9Xiwz6o z5HrMfN}Is6&b^n`KxUEp>nIyrwqUW|=?zs^Q7@7T7~G7k#b3SNjeJ<=b$fzIc`7r9 z$#Z_H{6XX4kWD`cm`SU}zv=rj7q5x@S;Zh5Log3AmkE{^jG@WCU*_o2?t#ttQHai2 z6R>1yw7TQ(WUj?#CQ^L@LIP@X(xp;}8u;!K&JUA-xuo*syb6PjMn3Xe6<<`uQ}m?y zY!UEwfLf2#=%yd1lKWoWQ4L-HCNfos__Mw`%Tq9(rkufF?CSJ!?VZrWCQ+XVX_+j0 z)J*V3U&A|f)#6tCT@{m-Mp`RE3^dx8e(OX_E|K-7aQ;OjO%IkaC*LzHVXvXutAolg z6H+*HL1Np1cSh}7buQt~w8jm0_*0`JXG;JBdWHtJ6M1PP<5_Xnd_v-9vjz4mo;R%T za?xp`T^!AV8QNe+i3bB>Em_@j7h@2E3o&OdxapaZ-+l(K4fxT8|Dcu*LEb5WJB-~| zg@9Z~I;-PtItYi1t5)ic?=zRw|XIoVV)#Kmj1g zn9V%TOFh{4^o}hs%qX3|llj$n`z@`uEgja7N5O~oJG79^p+&kvj_-xL3qCWl{oy2zc#(tuT_C>EZ#El~t z9-J0U=DxdwY2ml1vfO*^h(KRv?#m+j0 zPL3tyR$$coqi&xM%{)|i5M|DBCp8jg6rS=;q1&E}sipQV=K&(J-{eM?=Q(}|(&7i| zO5WBEop^*s3rvKZtB_^=-4d z$$of(*W#qhu@fx?H`aI1V4v*esJ_?Ui`$`&$4XrD-jv;i?b}t>Als`NuDY?dKB3|lbP8yn#@^==TGkSfYEJ>yOiL3Ddm;CT{%N=;7%Oc{X&65CXm2Qj}@o3w^j|esdVZ6xWtpaj*lzt9>}4U{Cw7Xgz*vc zD2W^HCzZ3EuY=7;cK-+}nFtjuh5^er{eAb8yyu3)$F*i@53lb*PEH}Ft1QAlXi^=47MuD%WgK(>N}ziwk1e?@-w6RqOn}rC%Uh}Rvt|z z(qjqRK6ANkHbliMH&I4^>|G{6;c?vhxJv4hZ$R*s6mb~V>s$nkS3BfKBDKjNzhANr zYN1?ohO1_vR%_aFti9!&jw_Ngo!457RCabmt1FLz136X7ubXP1zuWLD3=UV3cr0>- zpNs^uulVt&S(-!%$$K-`D-ai))=agBU)TAVnXJXjUlQ}{e9MTh?I`~pu>K(^7Gh2R50d^n fUi|}gm4`=!ET_M literal 4263 zcmb7IXH*l+(hf}m2tp8*-a-v6AOS%_4JE>hv>-;RGy&-)1Z)%uy$OUSh#(-{Pz(Vn zh7M8$rAtwY)PRUcyLs>T=lgrlp4~Y+vu9?`p4oY3Hum;E2CU3H%m4s@)yPoKoJ#Be zoj_XZ`mQF`g-R|s-7?Svoc}utTT9ac0QP%EdfJwlysiAOK+B2Np0+T>^uuEW$WUmJ ztNf`j6L)G%TyfDCq_r7`;K8V;wGVOLWv5|W#Ocw!)`

)QM_2RFD-~wlEEd>Z%s0 z0SL>tU&BU6X&D+~5{$&Njl~H&g4xb?Zo_%8*;B{HzumSpKjlpk_BD<-Pae0m4#UIJ zmsF&{AhBO54|O}WOfirYV0=;&AojgslZd0pTwGLaJs(J1aV13|HB+}!uba9Vt1`s@ zkJ3@j2RFbMi+@nHBvQ3pC^M*O*A?m9H? zT`A^ay_00ajj6oIvS0Z1!zI32$0tgS0^LVGm|JEvL`TB=Dp6!|fn4WX;>d&E+hy%) zQ6V0?DCNziFB(Gc(6P{kl9}6S184(wTtd4j3si|e7akpiYvO^a-3H=|M0dL{brM_> z-W3DnE2G#>JaaSXgO<;U7YT}(cp&_%=|?)T7DO!vRLB;_-!!}^D`I)Gh27xp$ao(* z2Bby{%m{23)t_$w>4*ek$h~dTT55*Vd?om0!t3$SuctG;(P1VN&FkT-HyEQ~WU`%cwy%*o{ zxoIUyA;EY5hV;)elZ7Msj>0GGVLV4)TROwpw(r*qlvgko!1g}azH&h;Z#C`JgzkV* zJ}&qswvh_XH>NYLo!{Pu#ybe*p=r8)*qsb}Bc*nlncyg1yIF)R>MW?5wMjA0T^ zhT}ZkE4ZaT_oc_G{Y)hoW&I_?_F%1H#C}#^$kX7Y4;!{AAUFobmf|h52wH> zrv~pN-@h)L%|?;e2-;E-?m1oUbPB_EkkK*Bz5cS- zlgvOxsi{xDtIn$dTG=$w+Wu*D#}nnjCmxIw{riZ-qkRDutc=!GAAZ17rF>wSAD>3# zD#X1lJXLM8&p65t3phH=BYO&3y3zr>~><)z7rNHO@OLKWkJ?IyjfsjW*hUTPe2kzJv+L$evye( z4v*pr5Z@4gnwxk_V{%so`^MtF_H)LuD*ERS`$hn+6l2lIjqqCyK=Y+}n z{2WFdF-hv{0|EXuy9)XEOuWThy9)L&EZwa52k@yfe5m`A$H^k0Hpf-ym&H_}q7=93 zqXM$t+o6K&sJ)|pd$Wb)u(cgc){4w$OeBS~Uotn_p6mUjkmhdN(8&-Sc{?jI0(bfp z{KTq_$@Bd|pLYl6z;et!PqY~?Se-_>!2VWY92u)_XndeWICd&0b$wkZCm9wCtk78U zSshn|Wpu-;e=TK)tgk0gxFp#*AwsPc7n8F%bpQ*I($f{*b(ax@Q7jYhnT~oxE%E@L zEoo~*do!HHrnQ&iH|bT@@>}`V&7tmcm{)jK@L{xL-eP}LE#F_@$%&l*a>FMjL6jN6 zgHYEw=Vh7S3E8P&MO`=f)58Z%o%B_)Sx|)b+ha`p4Qc96C0pAJBIdfXo7-R% z5p9&%Q}Z<~LZ&HQy`pTYs9)3>?AR5QbRS9i`skN@7){mC6t~jiCdyxFxAPP%+P76a z6%i+TJ9OeOA<%p?*lWt<+)n9C1R?GgLTHu4wK zvhol<29Jc@*c&2&f7TCstGWg+a#@XgnW68NGSzGYU!UhfImEri)Ib|M;$j3m_uv&v zftQ8zj)PH=Xd_$~;V@mNU`E!`a?~!d<&`vBP6)(C>d%2LxM%fDgKDaR8fgh5jX&@= zxQF3}Cm#as{S0#w>k1d~%gx*vENg7+Uiy$%hk=#i^fi{d4SXtB9p|L2s7V1yfTFH* z2h0+I$#W}s!ZI&`AtNS2Ueb6Jj~-p${lo9zn;R|x ze%b7fYB%T>fqbqnqd-ozi`xD}VOt%o2gwQE~s&Ox%&XwRNtMs$GExekQ+#^Y9p!H9(o?lKs)VQ6-+P{h3;am#3lX9mtXu%U9JTJ_Ap6 zAC6ZgjU1)X_fSX!$tYy){bLD{GhPNJ690_mtJw-$D3jX_0H1fe-Ndagp85O*qc!M% z5d~!({Gw`Ry}r+R+Ie0r!dRDc{@n7tz@}uU$#3p|P>`21#<`z%t6w_!Z-O5aGR)Pf z_g{kBkqOTXk(E+X#pyN};=~w;KtgtRBy4jwvTCNxWHV%;_h*n!+c>=gR$#j|e=t8$ z0WS=5*WEH|A;sIReV0WreD_}D?NKmdlFX0aG)~Kmz||N?7Ik5O>|-Bj+bisch3Ri?x_1mkNE8 zaDOa=_2J=-7a481|8x!G{AdVyKjJ68=md*%pIV_V(As*&u^WFf|Kj^uWZ2WDAr0KU zU*oK7RLRu+QEK9m#AIfGyLS-SqG`rjjbETOncOXT8N|XGH7u_NV*ze}hlH3o8=ZDNs0DS3uo2 z&tt#*FkzoogAG&Z6Itea#5m!suQOGVm=&%ZB_<9zv|{BE4Y6^=Z+14WYDYD{F2AUi z4pmaMCE+F-;{3t9jR7jk;>r`>x2>^643O8`><85(SG%dnEgfi%eYFR_R`Efy3ERC& zHxQoFSaWLoxSbl>`#j*Gx7*))uphN|MwXQ8OzU`R1N| zihqlL8GGxr!aPIMhk1zG#=H37X0zPr^aVHJZcryF?Now;^zLO^i+A9nadATBcgJmA z6AdeGCO8W>2^@qV{(UihfjN#daa?Wf8az9P8mTJB5<)z<8%s_OH~=7$rd&$WRtq(| z*6Y-?JiIgdxMV9hoKbGM;&!UyVYfD51MuM_?#fU=o=Rp)K8fMvYoLgePgms7dBsWK zZlKb9O>s|~rB&O8fW*d&?eeaOaJ6hFzf?2|pCy>F%|llZzUx`jQii&M1e-`yNi-ygrgu3C$8eyUd;kb($*h+oQ$ zx@b6O(>4lMRU|trIGBq|Np&tEl3eyz?Qfl(_FxWzY6g5Y%`8l3GUkV2hl_^FCC@&! zAKEo(uhj()GAMF;IE8Sv#7en`^t+!)0Qc<%UF zQcj4@{|NKshEMFh`lyPP(QE3M@Y3U{JyLGM_XRB|tFwd>-MRL-!rVnyZui%&26!~6 z5iVa%EJ>kcT+hRWEsw0TU){(onrj+?W#ZlkQ+z?tmEB!R9QS5$J!$hrW;Qfar`vkW zH%Eh0N~-b+f}tPT#j1XN0d)sVR!?Tkcp2vRs&afPMJ{+L{?WL)sjIm6J*0l}e(5Ws z7cLuW+4Pzf|`@9l?)J)iC5yWs0&ib9@5W-x)Fg z&U|cEX6fNbC-6aKz;6>Lkuh^4KWHX#Fum~d#Kibv^q?ewA~UIt=6y(F$KU+&t=Sc3 z{rOU=M$Fjfl$Xz|s{v<^-PtLv8K2^5 zW=mgB{Mrh+yoat*$hOST0FG4Tif+jGyNw3%t&%)wS8C^6mNpT2%UC)fpz zHnaLs`sj9CO zX>!Z`VTr8lS$vLC%wSIRitF|F#sj-rH9cu!U|H6QiC>#GnhZY=?ldNVC0;jX!c>m0 zvSY~)`9RyaYbi*2ZE|&`9{a!jFGn2&!a+Z!3O4?w*UZI4_g{d}l6Dj!y`y&4{)YHo tJheflN;ub1VB=zR>i_Gz|2OHGV3{~=02eSdhx+pXFhcyJSFPg^@gGvd84v&f diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png index e4acc94f673cb1784742c82f45c19b72d92a5294..8694e5ff347d6b2448a01cdbc2670c54d90498d5 100644 GIT binary patch literal 2297 zcmcJR_g4}O7spYo+`08}m7=CO)5wA47H2t#Kyl-O95@gcSf**O;GW@3OK_E@4K+1K zW@%4Jm|1SkoDb!QT#ude{s-@Q&pr2k&-vc_^Y?Spoi0NKc%^v(0Dyq4jit+x7X4{% z&ZAja@dSS)$9?UgmVm=QRnhq%7XaXHw6!#Y5vXgWVd1cke+`u9epNm^odrnY5GY=bfT2l$yb>a#3%Myx?^-#es~m7F|!_HB|J-wod&*@NJU zG^HXNjvHr%oJDxpqsLlatHkeY0+mEWn(doorXr_0y2ejBZ0mj-kK8fbhbltj$83*3i>9)M#To>f72j`$K}YU z<@3xQ8D#X$YPib(d>D%1c#=a+3Z6Ts#lw=vs3qctGgGk!$lGv}*1R&QJU0@RQZ+vK z#x#S~n+OF!wY^7$sqN;PHGKJpm@GHotnsg53%a*-D+SWW>+24_AkQ!ZzBSz#6vh$! zAC;WDffxXU_fNCpv&-r|iC(+FKHsz^P`>$Vb7)zAC^0AH+v2EXvQ&`Ji!v%XUd3gn zrvj-X`t!aE9(3MU%ydgyJo8Mzdr~3jr5<}|PI1N7YGn|*ah26u`_A;16om8GsEwTM zubH1MJTjTa?D4#Nd`OI=Ov+>D^Ntgip+>W)iaSkc3e~uP?%$pk4FVU}M>juX=2@2D zLzI;{S}pf&@jIZAK^ntM*H{9!Z@XyIlzHF7oy9i$SdIoavmx|?km>^x+A+6=saGh( z{c<>nmOgen%-!^8FZccD+@?N^JYl|I7*y55{aKu|y>9C*zlv)VUep> zPj~4W_uiaNlv9(2)8+aCxKPsu^HizQ-4ja#X;-5rC>3pHO>CMXwN4hWR!Z={+xR=j z?F673tW3JtdK^%TwT=aAfqjbH$#%D1rD-Ebgha#>tHTEcZcs^jcyhWA=$r&dr0R2( z{S4dc>={sf@{0REvl1SGF7p?nYGSbvw+WinmsIWLhs3(j;2P$8(Yf0XgR*mPgs&nZ zU3SuTwMuc?}%2>?zB&%}gco2mviON5JnHTrgB-7W=a0ZRx=5NS$ z4q$g27)$Wa4o7&v{{kBV&9y)6YX1&3sAeL^ z5$&-4IKXM|%E#un_nh=69(4jqU+8s*D;+bN0kRu|>{!CK@WQg)R6PABIol7hwLFA{s<8z9=0`xMYKsa&B34pebdEO^l*Hp z$$FW9qHe#m%{Iem zq@Chm*Z173me#qxxN$L6UDF2z3Glh1Jo$x|Y$gc8=nF>9KNYL@qJ4;S>^i1#-9(7V z{7+Z@caPhvS=Jf3nE5`IDzef#b9JsE@#<5NfyutnRs#>2U}V5_5yL2w*HabFgG=o2 zM}A`YEIk~O2{ecZl`l(lc;=DV)!xJ_p!r@LEiYJ`Z9)R5OVB4m1RiPRrCyVZ^^3w> zm+4q zhtEl7Y3VeuGS3Q}W*e*^*rKs&pu_0J=noj`CK`L9Y6fnxI(qJ0>8DKj!O7IJl&MCi z-nm{Q0|h~vpHRknV|N=K1&%25(2SkT+s}?9rg>?)E{5T@Vy5Q{f5ngwe(513T1&JT zIbJe2MM>i%FRd4A{Z+-N`d0%Xzu(4UD53p+?;7y*j1 z+zPdvPQC*%b-YpM5G}CahLdn%bp-^Q;OgPp~o^D>32ob6qOQRMlIz#;c5cICg4iWsn_74I8+tT6Axu47B3dENvLU zr8`QKev=Os;Lu zIKN!nz#Xxy!C8v7P+vf;WMBD{%;hlGNm2zByFZ;2s<%~HA7f_$y3|-r#n$Y7&1Aw? zH6H9qK(jO_!me*r#R)f!t4$=+kb`e(7&?S)cm+Kb0c7plG_VG|w$eu*pFQ3`V|SgH zNklnY)qNjZB^av`Q5Okay#Zc8j@93$g$2$BZAg83P^%hW&waG7bLG%5uG{?iHeMFe z7_-jY8Zd^qXuA6krFUF5=Tr%-eXk}}Y^hlzX(rGlNp}35plQS(e~^UpB9@&FB9T|L}y29H*_iC`s_>mjT#XUAAmA H_rCcr>!myf delta 3517 zcmV;u4MOty5zrfuHGd6-Nklc~DeG9>>2u2bTz zCYo9_Yt~eDtG04fVzw%qH5Fr$P3@+3Yd0}wlT9jSD~SnlUGYdoVl>7Gh=CDNQB)#G z#DhZ^k%0lG_m7!@VVL7_48J$c`_%hmW?sM7zt=s#{{6c9*MI$3@@5VN-$%ogdK3Z` zwEzmkg9eI53W0`EOQ0|;C>kjUij5Qm#cLG=MI!}4v5|tHc&&nBf zjT8jMMhb%BwF-iwk%FMuNI_7%RzXlSQVSUa$$3G9E{1UY&Hdy9H`kMSrDxOG# zN>+`71URrokM|2@p$qh0>oZw+G?7?_!NGu4a}nx~xAxQ5C9&7~V7#x^Vh>9a@bGME zApjLPsE*0Oc>Z?k0vHaST7a8E$%@f(s2D(XtV~9LmVdeuh6Ak{t5eB}(Q=S!@B8*Q07iHvJ)0Wldty}KL%8V3Bs{fx<~dFY842_rpPq%+6^F_CTlNYFjD?K$!z4M z7OI@R#eaosl)006t@;bdmchu@Fj8I%!hvfIRdY~U15+21;iTi>Fmn`CQQCbnjchey z&j~E`@e%VL4xL4VfzsKboc;LX2=C}5%zxPE>c-{*VmF*}_Tx$m-rgrdH)L}GuW6th zXa%Q^*~veS*ok;JnN*0O@$E_E7E?B=7U*MkHh=Bo1|uHNWQ@Ep22P$H@9yKAx(q=c zAze$rrm^c=ik)7lU8-s~s zr6%q&WHXFk*c!ulQxyw`6$72iGmxnxu&&<~$wx6?FeHAZ%!D;9E2ZuObt456%ma;oODWqr+<=dJ{oxWS}3`EoUjQ0I=f-T5fnqpE1+XIvzTOx3+Ur zm$xMJHX?YfVZnGUhs90-0HXRUoDzmN9%GT? ztzUN(dsfHvnTU(De&!sT_wlU-UVoHuWbkbpsf)sfl`Pb@9>}7O?k{0Lj?FrDb>nY& z{0BMB4-Xn2_FB_*cyT%Nw_CivvlxaO9-7Az4hhccXh6OixpY3Be9*%Zt=)<`+K{7-WDINWW6QEUBx^Sz zk9wW3e2|^js@34RzBHoJn>m`VqplHWY9&SKjU;U8A7!!7_2vkyU&AK&$d-|fE!bCzZ9njK z*{lX(OJAg~$lIpjPrqhk0;%N~APp^eXDdH;j#>@?Al=j4a%3dn)hB$f76F8Z;*~XQ z!7Snev>e3!NL@76=6^7)yj-pUpiyIO4r^%Pl{LhYP)3^jYq2)R=eMI%tMG>$78F1% z2Bp`Uor#F?o|DO+iD6hWA6xfP%0Q-(8a0;m4KDR*va|YxfsH(5d(q8~#W>0P+Q^@= zL6{v&xfdOo*E%Oo9Cfsiq+_GeYrWa+vVC79Is$}U05TgXQh&Q@VI1vyKd8J`wQx&p z1N|uXG$$)gDbb6*Me}va+{tWYlURY?0InHGZ6uYKb^5%_=7EOHsh|01^jgbK(d6qr z%1)0;uk~^R%Fj?`KKz`mK{aU@JLOpGwtPN&h9JWN|IO#Lu@;n_fQl-7S45cquu*sv z`U+whi030WZ-3*{rm|_i{C3x0F5~?#IeB<6afCo;CtlygPgN0@!l0?TfLAy2_IAoL zv>fV$wDq)Q(b8`rs=I;bB;qig}4F zDgu6_b9-*+>_lBXjvV6$f5MdpYH93Xk3KDzinJ#r7=MA(!4v&y??4ObIGh_~8ZcvM zg+W=JGZ5sv8cSh7$a;OGf^-y(6a?X>cSkEST8*VJ6o|c6e?KftNBDS0OWke8<*U5B z0?h`O*G`#)^kfA1JGb&?GY;!{jcsdkjRyB*AuQChOHf-cM05}y05bx(tgWTw^dgNLQvZ$zLRYt?EnYo8PA;vvTnjRg8o1 z=%N3q;?myJT6Mn!eIH{eS$n)(1!N>!Q={X2VxA3G-`{j7be$c$` zvYQK?Up|nmJzO4p3!h+H{--bJ`f{u56@MCn_!)!CdiW2@eq?{O=pke*9=OMwEdY~<8PmVG|}1dl;@IFe>D zKR*Ddy@*X8dYEWSor|a`juqX|gyIq>{g#2k7}9J8vh)}Sz&VbV^E(zW0I74?Cx2Ub zclS_p89Jo>NV}|Kq7f7ASQ&-I{Igtc@H7ROmEpXD80e4v2cr}cpgpIR))|Xk` zV0{_xSO5UQV~{YDm7gFiL@UHGJMNi3kG=Dj>XD_+du*PcO(iL7_Y$V3TGL){#knf$ zk0(!YlWm8cj8ytgBym5|KI>!0`G2-N>vxR?k&*1d`&i^;Xf#-s&A1uccX3zO{#eNC zE;-)YU_e2UwBRsPE;h2ip;DX(Iy83L!q; zdfVz6G&+k#uC7LXJtj^BfSEdJFIx8+lrcLU9jL4Gkj7}l2mj_JrH*A(UBf+=+tjJ< z&bHXrzBe~JxsgVLp7GUU*IP9gAF-Ya34w;x-yn|JdG9^*Ll3d=a7UHu?nYxHudGB- z5pQdA()3-d#YZ3UtbZ&71UOg6?rt17;BL)gZZ2QBlC`yA*G?zNx;wFF55Ma!R$Yw) z-@Cj|{vIAblSM^g?_S>BDQTF(OR&cum%%`x!jMtoS_MIgG#3O#BLzW;G#3OV(p(S} zjT8jMMhb%BwF-iwk%FMuNI_7%RzXlSQVs~{*EDF}*<6a>X<6@>o-S+2J7G$$cd00000NkvXXu0mjf3(&G| diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png index 2c2470067f823fb4d481ededf322d2fdfca96a24..a347e86acc69eb5913c8be529f83ed3fdcbed2bb 100644 GIT binary patch delta 2513 zcmZYBX*ksD8wYTd6D7>pvadz<-ARmXY-4Hc5hHYxH8Plt=7%wvNiz&>_9WRE%D%){ z#)(LdX6(x;WHhKO2_2ntUYzUye_r0t{k@+T_jNtj=atl|K)N2lU1$q6b4BHT%DV-3 zrHl3zJa5G(aBy%E^u+w2dC*iVi<81Ic&NRYj%mn8eqw!DSc#*ygNZhwtRZMOEvrYg zWOQQaky8bAwYxn{g}on@j< z&k7tKU%EAPZX1}1>TL@H-@V$@nO7~XUg*UxfMGBV7vO3MP6>7t`X;u$T7HUYNEJd@ zAJ(Zl29on`Vv>F}Os=IC()y`?s?F8%tI?A4SvjN#9351>{&(O1_ZZ!#gSJ{o5r^Tl zMPzbdxFHol&_P50L@D&>=NM89EPg?&pw6>>2F~CQPmG#t%_DL==HoNwNW_3*I12C7!GUxYloYHGB?<+T@ zQyl${w7s*aX4pXyqCWcv_MI}3AEgcW8W~yFllVcRiN0z=8%{Td0arUwo~jY=AmPx_ z50Il2eQP64mwHkce|Gchth+Q}ujWd=VV6|PZlQ0rm+DCxGVeE~fzb(12**gkI@U6| zL;Sw?Fs%El;F#Q?g29o=kce7`dR{hU+reC@xx40(l+H$0;b%$7bU3Ezs5Y?XIt^Zo zTJu@SnV6kh`Q646`7{``sG}yj;dE`ysS0a@yFhZQl2PT732F-lvEc`&7K&U|xI_e= z*jC~)O4l91LW&a57SqBmTh#P)m6uu5@~Z57G*~@^>0Bk_?cIF1QZ~5?UE;%$C9`Cg zdfv<+ruijB!to1Kros0JVE4)4b5VP_!JNLPa&&grf(KbHXBnMPWu^l8T?|(O)oGu- zx}hSRM<=K$c{g=say&NuA{hsLd2F&g=v3FyHZTBDxNlJRLx#+R@Y0Lw9P7EF7(Q4& zjS68cHuHAg%XYZR_Qzh((lQ89^7@E!z>~Q~#F*GGp!psRX(1pLKYar^De-HfiMx$P zm80TfG}|qyy$a zp9AkBLNOLnPYmrrb_Q?D*V#cFS}W?E*47KfG|PJDh^P5lI$2(a`Rz$mcwiEDlLUQEop}qIN#t_8!ohS8tK02T$)~ey2z;1{GT}D(88k%47p_FHs;;1ZfJVkdFx|)e zzhK7KxUo;(m@e=t*9i`u4F9@Mqtu(jf)c5$6qP*+(3GnrHnNP+IQaU^$Y%AXn|uUL z`a!S!nK)k){ZE;CU-KOdI6QVb@8&N181GE0d;TEbFW)s~FcUp<3HP`Ye;{8Iml_A` z=-nzxPcH^XqXnCCFMdydbnD?rX6j22qi@NiYxGjp^oKV+%n>rG0Xk^g{WAFEG%3G; z@ZOYw0O&~~vA?r&WJOMeuNE)``uw_KT53Wi*&`?|6qqmYD?tCVxV^4%w%ilKpMy5m zV;A=z`~hWCIv=_$Jhs=&4&Emg?F^Wm{q)v7Cch@ozHiqVByxnUBpVLM?f{YJoOBt> z&i?3PRTKX^=5CCwm4T$Jo!;Xh(T#5=+Y%1|tB#NSMce3gShJCHU`jcJA01~5NMUwM z*G-f!O@{U_J1X9}(Xl$4)4tOk^4T@>?C~-GZiL2Q*Q+G6MOB@cl3NFD8bnNyfVl5{ zHR%*@N$ya$c}d>N{RX9`>u*x(9j^T<-3H~;-7OAbG&vxmCaWmlYt!g7QrGCLz?VRP zXMfL~IJ4v1M)8SZ?2_Yh2B-Qg1NLEB0k7T+-?HMnGfJRJu+L5Z5;~!V{%)Uc!+pN; z?Nfz0gNj~BTlNE2_tV7qt~UFp3Xc~~HLxBcYQj#mN)Moo!@%!qgf|jdS1UuPqDQQb zt$Rv0NT%1kl=KQh*2Yv1JJM?GEWa{?>ORp@% z+`3OVTao1w};y#7HyJ9v(b!4+A4k5ykrY0~DWo1}W&AoX=KT#Ind&8KDi&lZg zP_`{s_e;hw1M|HwA;_8I!T+lFbNA)=gLM$Uwz_RQ6zxm(H50-4i z-)kBnJQsmSId{+#Ixu5Nx6J@Sh;21JkJrdaRjS{)DggPSWn*Rby;Fubn2g>!9juza zPyWy3~(9ZNViU7k|;W?nGv+3uuMa1xpqNeB8$qg>zzUG>S7|{Y>-AK4n!c zQ#7i*pt_Tqzh-ir@fu_b-FW!R!CycJxf;?X97|UPr9=~j_}-rA1o-D89$(3V&-5Q) zUyVJS;>0&@w(ebh=I1Up^rq>+?zj7A0@=w01%xZZ0)g4Kx&{PqX=yC-+SSaoo+|5FUHQ2-Ky+kXGw+2_}vO%tOjtkqD zApMkIv_(mH)MO~F$!KSPseSJ4wcy@8`M5$Hy){Y^VUufS0q3(X!M6+xpK%L0`!jjR zC!TfSe*e&thE0ih>|BpempdKvU_hWJ#`19nvp~KUU9h^`6DtK$U*WrUbZJy-FFGZD z+gLf0Wqr@cOv}#21-|Q}g%<7=w_Q^BV@}h zr%uWZxIMQ4P^U@n^v99Bv0dlF9U9^%D-JKDUVr2ooFDLy4 D%@pK) delta 3898 zcmZ{ncQo7m+sBQn6@;QSsy27aZO_IiN{w5oy<*geHdcbh{8}}GqNNqpnpLS)D>Omv zQM*>H+9gJe615)p?|07g{PkS_z0Z05ah=aO*XwiMo7Z~zUMK*}7y3H)%uzX8Z-e~J z$8L5tKXd>5f}Rp2@F1cxPCyxe|CnYL(q_n@iHlqQZn>kulYR1eX zcEXO-GY^>KbUc99{5%f8>(?-P;d8wojeIikoSK3Z*D~tLH44j!tWz7oI>9J4K`)2Y z?pzFCT7i79&Lx%RcbR#isSoTXgk;N!te(VRox_=f_yf{^@euF-#a;|72(`VKp{~~z z=tP?oz&!D$W~osO_J6f4+%mDb1K4t>f@^=P4_jxV{Rm*KrYek?S)9*))h_t&%y`ZY zCip3_CVLjvvNtXLOc_m?i?AkE9gEMYgLouqsJ|n~U?Iy7KL?ufS73}24MpktMcbc_d4(|AS4m(7>x!e7D!LI?Y8Z`TqK zX0}M#OI?$Uq^72h$wE%t$9|Ps=1jB-&=!y}Kq@=>N0Xlm9WPqUni#Qb_4o9Nm%0wS z4aq0^h1-y#4RUip)fVsuPM!Xa(88e7OP``6c zT;I1teC8ET6yN;QEUr@1_#<}`p$uRdg8YW;bz&gP* z6-+m-dOqoZW~GlPi$G@r(HK@89oRuV_wq|$ert4 z2Q^@|Tn3VrtJ7-~k3aTVS0&Gv?0r=BqDVLn=mh~AR}FpsQDDjGa8zGW2w0XqTzz17 z)VesWps-jjfv>|%WDKO`n4}V2xY%h8Z%G9xS9)pyz&pn)PlRB}BPMOwyoqVjAMf)Z zcM4R1$jJEY;@A+j%=$=1!>HD5o04l{_VZ9dUY3RPm}M@q$7iudL^jUK!-QA&i=$as z5j${4{ig;e?d!)rmv^PgD2DSIlFO<>+>v5h7K70D*Zg_e?Q1lOFZFlgmGB0gb*r5E znK>z`_^0;=q_iUUZFI!dHA;J`B^$M8hs>%;8v_yRFGKjrA>L|_E4|f;Ba}Xyi?-qL z3%j&J_h0Y$I-3uDQ#6xT45DVnX#={lg8+%rn~A+?rCU9<9oI0%TR)T)Hy1DZNRgpY zbgOXh8BK@+M!{$}yfSsdmf$#YVQKxRA*X8m@fmZIRF@cAq9829TTbusG5bk|bqB{J z){6}95x%ZnQPP<$2|00*zI>B=Fe1oWnw4?ezwUdcd|7Nc`q%vx>xMUL4*QYuKt6pP z&RhOx!n%Z8;l7a{%^~lo|CURKS@!Wn6+%v`@XWXUp=b$$9GHg+X0ASXrqUpiY*#kt z;NR?RtgVq7S8EsK7Eq~js2kNae)qmhfAl9urL2sOl?P+)zmi0!n9ino@dq*$Wluxs z*TKSmgr{Pk?AjaeE2%Y$Bu_k<1ZuuGDuryEuiwMhV)ow;CD29*#6A3#;&HvQ#2quu z{>pjB^dD&Oumh;2Q!TWMl3%>j8FG!pryfL+o88y?J+N-7&M~9F9Ykn!It$DV%Acd zYQDDv#VLVyc{tvWKR^!eu0ZWs6c&zHDtGy^_`}G_#b1?Y*_`U;nr>SD?HawLHkKB< z{Yo>D=%DJ{?=r5|X;_=ul*T&CNcosZaM|5_#JT0g*t>|>)X$3De%=yKOb8==qjG0H zg6<1(4J&{ts->3V7VD93fXv@Sk(CE(RDYTuD1Y|~JG>(CCO-hf?fHv4Z+Zj0uy8Vo z_LIad=c^wUc&Wix&Dd`pFv%<^At$#IB)^n)CZ;3i=?Yh-pPHH^Vmp*`+8!={)itdM z6t8Pk;T@E4(I!X=6dU<*DHiQXrADn5N@9+M%Pdp3BgR;&$mRJf0F3c7a(B@ks_T;n zN;q5KF|3E-53FCvt3>35kxmhuqxbMG81~yI-BCiayjWl$|8BfDREd|XI5#FPl_+RS)jZT7Bo5=zf#4h7{%iNH!+-7mIQ(rCsHw}fQ1<9y>$uOA zox6mWbU7?=sstRAt+1~hV}giIv=T~ddpGbQ4+Pvos-`mjN92N-AcKEJ{%xlN`$Jvu zjx7g;?=Hh0HIfIql{3&*D>$sBEG^B<;1=@a#ognrkM_899!7dQTHjyF1DD@StB32m z4jBVev;h{GVl}tIp>)4*fx7oL>FA#t2v-8UJdgd=PoJGVZl>w(D`1N;)Ug-z(QN=q z_s2`{ zS~|OlM=w_jby)iF|IN9SVLf8ft}7&{S?qBe>stn17bBm8>_tvv>IpnwA^fc=2Ij+=S3bTfGdL z)uFmP#$>Q8On7y6@XwL1HEcAswA8N*{6_3JQYiONyEdCIz>w5Hb5$u4I~Tj8g7U#2 zX~}u?C|^!FrD-?3O3umObDAUwei~yJ4}#s)lo=d6wvQz?DleX zH*S*bDiy$xNZPQ2?Bw_AnQrZF*mCl%o+X{1GF+U0IwMzLZR#^6vH%ap`IF?{v#Gp*Pj8d=UMu$NH`uQsR--6qbD&@Xl2 za@KiL1?VNeRBH&ZPrUL14(BYP8z|p80$07wB!8D+)nVF$H{Eq%#R9u2}w;yHwMIrDnSU3877Z*_c9o-~ag4(@C67MrQ>S=+d3gINxi1%!`~J}g>N9~WY_SS1fKi=-imb_B9KP=rWWzOR zKNa4!l`Fp!99VBP02ydt0>R{uNYZFtUAO+m&*1)5DD(p`QuL{$-RgvVc!8+E1c)AZ z_%!`E)u0?3LwU#zLWbSXz0e)Ul3X^2(T{Cjt4@VO#-rK}pfko}ZCF17h2@=m!MB9J zuCr^j!y-0(F)CDMVnX}4)e3W_1BOmjTf%taChwfd@c|b!XjFYp$>jzki^Z*hYjmb8 zKsCcFr7fUn&Uq><*!d7t7hWddiEEtxuH_A7Y$w@Q>+vq~Ryr^d8-xOD)> zV84%k{@U<(*X@~;mA*soU}~!h)zmI@=d(C@04Q+e=bm`=ePU_E4ZkZ2jGzUgZ$u24 z6dJkjG~9~&Sh6gJ3_ah0xYtyV?O-`1RlpSW5 zZvGQTYFg=4)rP!JgF}g?+!KtmvtmtRjh#;#-}S^4vcfMYWfMgeP8MI4K3S{EY%qN% z3y4k~JKCBpebeGLOcsnb47|wJ7XT*{ODPrdn)k`N`?KnyG6qYc<#MsE_f3Fsf}~P~ z=UbdIqsf%C~Ks#SD9A_WKv6W*_gAtxHvspersVd`6Wv!%|7V(edOj@ zy+w_^Pg@WC{09+l+!ivGmkp($&=pqo$@P2LRy^lpH271lcv^!?9pvem-SnLW+`C|C z$hwZQ&j&Pc#K?*9#bhRYP0jJj%+n9EFijUh!y6PVTuoye_|z{)F~2SF8KP>y>{i*x z=-V>BTZ2Emz2Db@_edelq`hua0+ZgzaiT@tCj3=!P>}Mofs^|0V++BNA1YLko#7@9 z(Rj%3?Bs___Kh*49_CmI&2yUN)gP&hj34DyDC%7G2{5jlgI|$NVQ;235hL!}_h}3a ze9s{jdy_;3?zGq4jsAZ0v<9C5%jIR#E#Lr=k#NmsZ9v&Ser*jqA%jUvZ*6U@d?qC& zao}H{a`Q4O>pdw=1lVZGEVIb8rD{qEQfW^e590rows>3?nMtcao~>En-=c^s*K=jA z(EG2&|F0Zgl7dT@-BC^HzkTvQnA|1z|M$#eja%*{mUKQQ48`T>-#6AN*K&&bAC+gJ AGXMYp diff --git a/tools/generate-logos b/tools/generate-logos index 750cf1a091..a1c95c37d6 100755 --- a/tools/generate-logos +++ b/tools/generate-logos @@ -45,9 +45,8 @@ jq --version >/dev/null 2>&1 \ || die "Need jq -- try 'apt install jq'." -# White Z above "BETA", on transparent background. -# TODO(#715): use the non-beta version -src_icon_foreground="${root_dir}"/assets/app-icons/zulip-white-z-beta-on-transparent.svg +# White Z, on transparent background. +src_icon_foreground="${root_dir}"/assets/app-icons/zulip-white-z-on-transparent.svg # Gradient-colored square, full-bleed. src_icon_background="${root_dir}"/assets/app-icons/zulip-gradient.svg @@ -55,8 +54,7 @@ src_icon_background="${root_dir}"/assets/app-icons/zulip-gradient.svg # Combination of ${src_icon_foreground} upon ${src_icon_background}... # more or less. (The foreground layer is larger, with less padding, # and the SVG is different in random other ways.) -# TODO(#715): use the non-beta version -src_icon_combined="${root_dir}"/assets/app-icons/zulip-beta-combined.svg +src_icon_combined="${root_dir}"/assets/app-icons/zulip-combined.svg make_one_ios_app_icon() { From 2ab189d2fc35886608dadb488b15919f3545ca25 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Sat, 14 Jun 2025 15:23:52 +0530 Subject: [PATCH 201/290] android, ios: Update app name to "Zulip", from "Zulip beta" --- android/app/src/main/AndroidManifest.xml | 2 +- ios/Runner/Info.plist | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a0f602e899..fa2c342af5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Zulip beta + Zulip CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Zulip beta + Zulip CFBundlePackageType APPL CFBundleShortVersionString From 40046c85d7435fcaf8b20b5b6a41dedac857a412 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Sat, 14 Jun 2025 17:04:21 +0530 Subject: [PATCH 202/290] notif: Use a different channel ID from previous values of zulip-mobile Previous values list is sourced from here: https://github.com/zulip/zulip-mobile/blob/eb8505c4a/android/app/src/main/java/com/zulipmobile/notifications/NotificationChannelManager.kt#L22-L24 --- lib/notifications/display.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 0a3de1689a..74f0d1985a 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -37,11 +37,12 @@ enum NotificationSound { class NotificationChannelManager { /// The channel ID we use for our one notification channel, which we use for /// all notifications. - // TODO(launch) check this doesn't match zulip-mobile's current or previous - // channel IDs - // Previous values: 'messages-1' + // Previous values from Zulip Flutter Beta: + // 'messages-1' + // Previous values from Zulip Mobile: + // 'default', 'messages-1', (alpha-only: 'messages-2'), 'messages-3' @visibleForTesting - static const kChannelId = 'messages-2'; + static const kChannelId = 'messages-4'; @visibleForTesting static const kDefaultNotificationSound = NotificationSound.chime3; From a4a96a1c50197643b0f4f2dc0b7416b51f1f8632 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Sat, 14 Jun 2025 16:58:14 +0530 Subject: [PATCH 203/290] notif: Use PackageInfo to generate sound resource file URL --- lib/notifications/display.dart | 14 ++++++------- test/notifications/display_test.dart | 31 ++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 74f0d1985a..72d833f03e 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -62,11 +62,11 @@ class NotificationChannelManager { /// `android.resource://com.zulip.flutter/raw/chime3` /// /// Based on: https://stackoverflow.com/a/38340580 - static Uri _resourceUrlFromName({ + static Future _resourceUrlFromName({ required String resourceTypeName, required String resourceEntryName, - }) { - const packageName = 'com.zulip.flutter'; // TODO(#407) + }) async { + final packageInfo = await ZulipBinding.instance.packageInfo; // URL scheme for Android resource url. // See: https://developer.android.com/reference/android/content/ContentResolver#SCHEME_ANDROID_RESOURCE @@ -74,9 +74,9 @@ class NotificationChannelManager { return Uri( scheme: schemeAndroidResource, - host: packageName, + host: packageInfo!.packageName, pathSegments: [resourceTypeName, resourceEntryName], - ); + ).toString(); } /// Prepare our notification sounds; return a URL for our default sound. @@ -87,9 +87,9 @@ class NotificationChannelManager { /// Returns a URL for our default notification sound: either in shared storage /// if we successfully copied it there, or else as our internal resource file. static Future _ensureInitNotificationSounds() async { - String defaultSoundUrl = _resourceUrlFromName( + String defaultSoundUrl = await _resourceUrlFromName( resourceTypeName: 'raw', - resourceEntryName: kDefaultNotificationSound.resourceName).toString(); + resourceEntryName: kDefaultNotificationSound.resourceName); final shouldUseResourceFile = switch (await ZulipBinding.instance.deviceInfo) { // Before Android 10 Q, we don't attempt to put the sounds in shared media storage. diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 7fe04c73ee..c4763b27ef 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -210,8 +210,8 @@ void main() { NotificationChannelManager.kDefaultNotificationSound.resourceName; String fakeStoredUrl(String resourceName) => testBinding.androidNotificationHost.fakeStoredNotificationSoundUrl(resourceName); - String fakeResourceUrl(String resourceName) => - 'android.resource://com.zulip.flutter/raw/$resourceName'; + String fakeResourceUrl({required String resourceName, String? packageName}) => + 'android.resource://${packageName ?? eg.packageInfo().packageName}/raw/$resourceName'; test('on Android 28 (and lower) resource file is used for notification sound', () async { addTearDown(testBinding.reset); @@ -227,7 +227,30 @@ void main() { .isEmpty(); check(androidNotificationHost.takeCreatedChannels()) .single - .soundUrl.equals(fakeResourceUrl(defaultSoundResourceName)); + .soundUrl.equals(fakeResourceUrl(resourceName: defaultSoundResourceName)); + }); + + test('generates resource file URL from app package name', () async { + addTearDown(testBinding.reset); + final androidNotificationHost = testBinding.androidNotificationHost; + + testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.example.test'); + + // Force the default sound URL to be the resource file URL, by forcing + // the Android version to the one where we don't store sounds through the + // media store. + testBinding.deviceInfoResult = + const AndroidDeviceInfo(sdkInt: 28, release: '9'); + + await NotificationChannelManager.ensureChannel(); + check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()) + .isEmpty(); + check(androidNotificationHost.takeCreatedChannels()) + .single + .soundUrl.equals(fakeResourceUrl( + resourceName: defaultSoundResourceName, + packageName: 'com.example.test', + )); }); test('notification sound resource files are being copied to the media store', () async { @@ -315,7 +338,7 @@ void main() { .isEmpty(); check(androidNotificationHost.takeCreatedChannels()) .single - .soundUrl.equals(fakeResourceUrl(defaultSoundResourceName)); + .soundUrl.equals(fakeResourceUrl(resourceName: defaultSoundResourceName)); }); }); From e0e51b1abcd0d9a17cbb271ec2313dc1049955ad Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Sat, 14 Jun 2025 22:22:35 +0530 Subject: [PATCH 204/290] android: Switch app ID to that of the main app On Android, switch to using "com.zulipmobile" as the application ID. But keep using "com.zulip.flutter" as the JVM package name for Java/Kotlin code. Updates: #1582 --- android/app/build.gradle | 2 +- lib/model/store.dart | 2 +- lib/notifications/display.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 84ad671523..c56eeb88a7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -38,7 +38,7 @@ android { } defaultConfig { - applicationId "com.zulip.flutter" + applicationId "com.zulipmobile" minSdkVersion 28 targetSdkVersion flutter.targetSdkVersion // These are synced to local.properties from pubspec.yaml by the flutter tool. diff --git a/lib/model/store.dart b/lib/model/store.dart index 8fad731f5c..440634d76b 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -1078,7 +1078,7 @@ class LiveGlobalStore extends GlobalStore { // What directory should we use? // path_provider's getApplicationSupportDirectory: // on Android, -> Flutter's PathUtils.getFilesDir -> https://developer.android.com/reference/android/content/Context#getFilesDir() - // -> empirically /data/data/com.zulip.flutter/files/ + // -> empirically /data/data/com.zulipmobile/files/ // on iOS, -> "Library/Application Support" via https://developer.apple.com/documentation/foundation/nssearchpathdirectory/nsapplicationsupportdirectory // on Linux, -> "${XDG_DATA_HOME:-~/.local/share}/com.zulip.flutter/" // All seem reasonable. diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 72d833f03e..7a66b1d19f 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -59,7 +59,7 @@ class NotificationChannelManager { /// For example, for a resource `@raw/chime3`, where `raw` would be the /// resource type and `chime3` would be the resource name it generates the /// following URL: - /// `android.resource://com.zulip.flutter/raw/chime3` + /// `android.resource://com.zulipmobile/raw/chime3` /// /// Based on: https://stackoverflow.com/a/38340580 static Future _resourceUrlFromName({ From 378e187583da8032ce0f86fbcc30de6b3f381c03 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Sat, 14 Jun 2025 22:25:18 +0530 Subject: [PATCH 205/290] ios: Switch app ID to that of the main app On iOS, switch to using "org.zulip.Zulip" as the product bundle identifier. Fixes: #1582 --- docs/howto/push-notifications-ios-simulator.md | 6 +++--- ios/Runner.xcodeproj/project.pbxproj | 6 +++--- ios/Runner/Info.plist | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md index d54bb20491..4ec1d6090a 100644 --- a/docs/howto/push-notifications-ios-simulator.md +++ b/docs/howto/push-notifications-ios-simulator.md @@ -64,15 +64,15 @@ receive a notification on the iOS Simulator for the zulip-flutter app. Tapping on the notification should route to the respective conversation. ```shell-session -$ xcrun simctl push [device-id] com.zulip.flutter [payload json path] +$ xcrun simctl push [device-id] org.zulip.Zulip [payload json path] ```

Example output: ```shell-session -$ xcrun simctl push 90CC33B2-679B-4053-B380-7B986A29F28C com.zulip.flutter ./dm.json -Notification sent to 'com.zulip.flutter' +$ xcrun simctl push 90CC33B2-679B-4053-B380-7B986A29F28C org.zulip.Zulip ./dm.json +Notification sent to 'org.zulip.Zulip' ```
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7df051a142..37f8231c8d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -392,7 +392,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -522,7 +522,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -546,7 +546,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.zulip.flutter; + PRODUCT_BUNDLE_IDENTIFIER = org.zulip.Zulip; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index dbac5b20df..d86c7afca7 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -26,7 +26,7 @@ CFBundleURLName - com.zulip.flutter + org.zulip.Zulip CFBundleURLSchemes zulip From 5be0e70cee7a3c908b19475f3b1ea05bdd983dff Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 15 Jun 2025 13:52:52 -0700 Subject: [PATCH 206/290] l10n: Add translatable strings from welcome dialog in v30.0.256 release --- assets/l10n/app_en.arb | 16 +++++++++++++ lib/generated/l10n/zulip_localizations.dart | 24 +++++++++++++++++++ .../l10n/zulip_localizations_ar.dart | 14 +++++++++++ .../l10n/zulip_localizations_de.dart | 14 +++++++++++ .../l10n/zulip_localizations_en.dart | 14 +++++++++++ .../l10n/zulip_localizations_it.dart | 14 +++++++++++ .../l10n/zulip_localizations_ja.dart | 14 +++++++++++ .../l10n/zulip_localizations_nb.dart | 14 +++++++++++ .../l10n/zulip_localizations_pl.dart | 14 +++++++++++ .../l10n/zulip_localizations_ru.dart | 14 +++++++++++ .../l10n/zulip_localizations_sk.dart | 14 +++++++++++ .../l10n/zulip_localizations_sl.dart | 14 +++++++++++ .../l10n/zulip_localizations_uk.dart | 14 +++++++++++ .../l10n/zulip_localizations_zh.dart | 14 +++++++++++ 14 files changed, 208 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index e03f421761..96ce49ffda 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -15,6 +15,22 @@ "@aboutPageTapToView": { "description": "Item subtitle in About Zulip page to navigate to Licenses page" }, + "upgradeWelcomeDialogTitle": "Welcome to the new Zulip app!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "You’ll find a familiar experience in a faster, sleeker package.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Check out the announcement blog post!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Let's go", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, "chooseAccountPageTitle": "Choose account", "@chooseAccountPageTitle": { "description": "Title for the page to choose between Zulip accounts." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index c13cdd3a9f..cbf3e6841b 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -153,6 +153,30 @@ abstract class ZulipLocalizations { /// **'Tap to view'** String get aboutPageTapToView; + /// Title for dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Welcome to the new Zulip app!'** + String get upgradeWelcomeDialogTitle; + + /// Message text for dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'You’ll find a familiar experience in a faster, sleeker package.'** + String get upgradeWelcomeDialogMessage; + + /// Text of link in dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Check out the announcement blog post!'** + String get upgradeWelcomeDialogLinkText; + + /// Label for button dismissing dialog shown on first upgrade from the legacy Zulip app. + /// + /// In en, this message translates to: + /// **'Let\'s go'** + String get upgradeWelcomeDialogDismiss; + /// Title for the page to choose between Zulip accounts. /// /// 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 c47095ee06..5721604624 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 301f70d34d..43dfedc1d5 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Konto auswählen'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 52e4393767..0b96cb55eb 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index cb6dc8a2ce..3382e76c02 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap per visualizzare'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Scegli account'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 1fdc2f585d..8a5d609fe2 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'アカウントを選択'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 4bdd16533d..14a250b68a 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index e046358d11..cf867ed9b6 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get aboutPageTapToView => 'Dotknij, aby pokazać'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Wybierz konto'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 57b33f03b1..09a97476f6 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get aboutPageTapToView => 'Нажмите для просмотра'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Выберите учетную запись'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 29e203cccb..0477e68eee 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get aboutPageTapToView => 'Klepnutím zobraziť'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Zvoliť účet'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index c69ff12045..604e924d85 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get aboutPageTapToView => 'Dotaknite se za ogled'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Izberite račun'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6c5e7264d1..9c406256fa 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get aboutPageTapToView => 'Натисніть, щоб переглянути'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Обрати обліковий запис'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index d61e7cd6e3..b0d195420b 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -20,6 +20,20 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get aboutPageTapToView => 'Tap to view'; + @override + String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + + @override + String get upgradeWelcomeDialogMessage => + 'You’ll find a familiar experience in a faster, sleeker package.'; + + @override + String get upgradeWelcomeDialogLinkText => + 'Check out the announcement blog post!'; + + @override + String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + @override String get chooseAccountPageTitle => 'Choose account'; From fec0071a0225377023d183f651e9d42cec650b4e Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Sun, 15 Jun 2025 12:01:51 +0200 Subject: [PATCH 207/290] l10n: Update translations from Weblate. --- assets/l10n/app_de.arb | 1158 +++++++++++++++++ assets/l10n/app_it.arb | 898 ++++++++++++- assets/l10n/app_pl.arb | 28 + assets/l10n/app_ru.arb | 28 + assets/l10n/app_zh_Hans_CN.arb | 44 +- .../l10n/zulip_localizations_de.dart | 498 +++---- .../l10n/zulip_localizations_it.dart | 386 +++--- .../l10n/zulip_localizations_pl.dart | 16 +- .../l10n/zulip_localizations_ru.dart | 15 +- .../l10n/zulip_localizations_zh.dart | 38 +- 10 files changed, 2656 insertions(+), 453 deletions(-) diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb index b4854e64c5..c7731cd293 100644 --- a/assets/l10n/app_de.arb +++ b/assets/l10n/app_de.arb @@ -22,5 +22,1163 @@ "aboutPageOpenSourceLicenses": "Open-Source-Lizenzen", "@aboutPageOpenSourceLicenses": { "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "newDmSheetComposeButtonLabel": "Verfassen", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetScreenTitle": "Neue DN", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "Neue DN", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "unknownChannelName": "(unbekannter Kanal)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "composeBoxTopicHintText": "Thema", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "composeBoxEnterTopicOrSkipHintText": "Gib ein Thema ein (leer lassen für “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "contentValidationErrorTooLong": "Nachrichtenlänge sollte nicht größer als 10000 Zeichen sein.", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "contentValidationErrorEmpty": "Du hast nichts zum Senden!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "errorDialogLearnMore": "Mehr erfahren", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "snackBarDetails": "Details", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "loginMethodDivider": "ODER", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "topicValidationErrorTooLong": "Länge des Themas sollte 60 Zeichen nicht überschreiten.", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "spoilerDefaultHeaderText": "Spoiler", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAllAsReadLabel": "Alle Nachrichten als gelesen markieren", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "userRoleOwner": "Besitzer", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleAdministrator": "Administrator", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "inboxEmptyPlaceholder": "Es sind keine ungelesenen Nachrichten in deinem Eingang. Verwende die Buttons unten um den kombinierten Feed oder die Kanalliste anzusehen.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "recentDmConversationsSectionHeader": "Direktnachrichten", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "recentDmConversationsEmptyPlaceholder": "Du hast noch keine Direktnachrichten! Warum nicht die Unterhaltung beginnen?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "starredMessagesPageTitle": "Markierte Nachrichten", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "channelsPageTitle": "Kanäle", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "channelsEmptyPlaceholder": "Du hast noch keine Kanäle abonniert.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "onePersonTyping": "{typist} tippt…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "errorReactionAddingFailedTitle": "Hinzufügen der Reaktion fehlgeschlagen", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "wildcardMentionTopicDescription": "Thema benachrichtigen", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "messageIsEditedLabel": "BEARBEITET", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingTitle": "THEMA", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "errorNotificationOpenAccountNotFound": "Der Account, der mit dieser Benachrichtigung verknüpft ist, konnte nicht gefunden werden.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "initialAnchorSettingTitle": "Nachrichten-Feed öffnen bei", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "Erste ungelesene Nachricht", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "Erste ungelesene Nachricht in Einzelunterhaltungen, sonst neueste Nachricht", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "revealButtonLabel": "Nachricht für stummgeschalteten Absender anzeigen", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "actionSheetOptionListOfTopics": "Themenliste", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionUnresolveTopic": "Als ungelöst markieren", + "@actionSheetOptionUnresolveTopic": { + "description": "Label for the 'Mark as unresolved' button on the topic action sheet." + }, + "errorResolveTopicFailedTitle": "Thema konnte nicht als gelöst markiert werden", + "@errorResolveTopicFailedTitle": { + "description": "Error title when marking a topic as resolved failed." + }, + "actionSheetOptionCopyMessageText": "Nachrichtentext kopieren", + "@actionSheetOptionCopyMessageText": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionCopyMessageLink": "Link zur Nachricht kopieren", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, + "actionSheetOptionUnstarMessage": "Markierung aufheben", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, + "errorCouldNotFetchMessageSource": "Konnte Nachrichtenquelle nicht abrufen.", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "errorLoginFailedTitle": "Anmeldung fehlgeschlagen", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorCouldNotOpenLink": "Link konnte nicht geöffnet werden: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "errorMuteTopicFailed": "Konnte Thema nicht stummschalten", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorCouldNotEditMessageTitle": "Konnte Nachricht nicht bearbeiten", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxBannerLabelEditMessage": "Nachricht bearbeiten", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonCancel": "Abbrechen", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "preparingEditMessageContentInput": "Bereite vor…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "discardDraftConfirmationDialogConfirmButton": "Verwerfen", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "messageListGroupYouAndOthers": "Du und {others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "unknownUserName": "(Nutzer:in unbekannt)", + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "dialogCancel": "Abbrechen", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "errorMalformedResponseWithCause": "Server lieferte fehlerhafte Antwort; HTTP Status {httpStatus}; {details}", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + }, + "details": { + "type": "String", + "example": "type 'Null' is not a subtype of type 'String' in type cast" + } + } + }, + "userRoleModerator": "Moderator", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "userRoleGuest": "Gast", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "userRoleMember": "Mitglied", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "userRoleUnknown": "Unbekannt", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "unpinnedSubscriptionsLabel": "Nicht angeheftet", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "wildcardMentionChannelDescription": "Kanal benachrichtigen", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "wildcardMentionStreamDescription": "Stream benachrichtigen", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "experimentalFeatureSettingsWarning": "Diese Optionen aktivieren Funktionen, die noch in Entwicklung und nicht bereit sind. Sie funktionieren möglicherweise nicht und können Problem in anderen Bereichen der App verursachen.\n\nDer Zweck dieser Einstellungen ist das Experimentieren der Leute, die an der Entwicklung von Zulip arbeiten.", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "savingMessageEditLabel": "SPEICHERE BEARBEITUNG…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "BEARBEITUNG NICHT GESPEICHERT", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Die Nachricht, die du schreibst, verwerfen?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftForOutboxConfirmationDialogMessage": "Wenn du eine nicht gesendete Nachricht wiederherstellst, wird der vorherige Inhalt der Nachrichteneingabe verworfen.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "dialogContinue": "Fortsetzen", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "loginServerUrlLabel": "Deine Zulip Server URL", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginErrorMissingEmail": "Bitte gib deine E-Mail ein.", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "loginErrorMissingPassword": "Bitte gib dein Passwort ein.", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "actionSheetOptionQuoteMessage": "Nachricht zitieren", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "markReadOnScrollSettingAlways": "Immer", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "actionSheetOptionStarMessage": "Nachricht markieren", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "errorAccountLoggedInTitle": "Account bereits angemeldet", + "@errorAccountLoggedInTitle": { + "description": "Error title on attempting to log into an account that's already logged in." + }, + "actionSheetOptionEditMessage": "Nachricht bearbeiten", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, + "composeBoxGenericContentHint": "Eine Nachricht eingeben", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "actionSheetOptionMarkAsUnread": "Ab hier als ungelesen markieren", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, + "errorUnresolveTopicFailedTitle": "Thema konnte nicht als ungelöst markiert werden", + "@errorUnresolveTopicFailedTitle": { + "description": "Error title when marking a topic as unresolved failed." + }, + "logOutConfirmationDialogMessage": "Um diesen Account in Zukunft zu verwenden, musst du die URL deiner Organisation und deine Account-Informationen erneut eingeben.", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "actionSheetOptionMarkTopicAsRead": "Thema als gelesen markieren", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, + "errorHandlingEventDetails": "Fehler beim Verarbeiten eines Zulip-Ereignisses von {serverUrl}; Wird wiederholt.\n\nFehler: {error}\n\nEreignis: {event}", + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "https://chat.example.com" + }, + "error": { + "type": "String", + "example": "Unexpected null value" + }, + "event": { + "type": "String", + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + } + } + }, + "markReadOnScrollSettingConversationsDescription": "Nachrichten werden nur beim Ansehen einzelner Themen oder Direktnachrichten automatisch als gelesen markiert.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "markAsReadComplete": "{num, plural, =1{Eine Nachricht} other{{num} Nachrichten}} als gelesen markiert.", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "contentValidationErrorUploadInProgress": "Bitte warte bis das Hochladen abgeschlossen ist.", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "composeBoxBannerButtonSave": "Speichern", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "loginEmailLabel": "E-Mail-Adresse", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "dialogClose": "Schließen", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "loginHidePassword": "Passwort verstecken", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "markAsUnreadComplete": "{num, plural, =1{Eine Nachricht} other{{num} Nachrichten}} als ungelesen markiert.", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "topicsButtonLabel": "THEMEN", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "markReadOnScrollSettingTitle": "Nachrichten beim Scrollen als gelesen markieren", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "errorMarkAsReadFailedTitle": "Als gelesen markieren fehlgeschlagen", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "pinnedSubscriptionsLabel": "Angeheftet", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "initialAnchorSettingDescription": "Du kannst auswählen ob Nachrichten-Feeds bei deiner ersten ungelesenen oder bei den neuesten Nachrichten geöffnet werden.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingNever": "Nie", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "initialAnchorSettingNewestAlways": "Neueste Nachricht", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingDescription": "Sollen Nachrichten automatisch als gelesen markiert werden, wenn du sie durchscrollst?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Nur in Unterhaltungsansichten", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "errorAccountLoggedIn": "Der Account {email} auf {server} ist bereits in deiner Account-Liste.", + "@errorAccountLoggedIn": { + "description": "Error message on attempting to log into an account that's already logged in.", + "placeholders": { + "email": { + "type": "String", + "example": "user@example.com" + }, + "server": { + "type": "String", + "example": "https://example.com" + } + } + }, + "errorCopyingFailed": "Kopieren fehlgeschlagen", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "actionSheetOptionHideMutedMessage": "Stummgeschaltete Nachricht wieder ausblenden", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "errorMessageNotSent": "Nachricht nicht versendet", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "errorMessageEditNotSaved": "Nachricht nicht gespeichert", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "editAlreadyInProgressTitle": "Kann Nachricht nicht bearbeiten", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "Eine Bearbeitung läuft gerade. Bitte warte bis sie abgeschlossen ist.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "discardDraftForEditConfirmationDialogMessage": "Wenn du eine Nachricht bearbeitest, wird der vorherige Inhalt der Nachrichteneingabe verworfen.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "newDmSheetNoUsersFound": "Keine Nutzer:innen gefunden", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "newDmSheetSearchHintEmpty": "Füge ein oder mehrere Nutzer:innen hinzu", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetSearchHintSomeSelected": "Füge weitere Nutzer:in hinzu…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "lightboxVideoCurrentPosition": "Aktuelle Position", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "lightboxCopyLinkTooltip": "Link kopieren", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "serverUrlValidationErrorInvalidUrl": "Bitte gib eine gültige URL ein.", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "errorRequestFailed": "Netzwerkanfrage fehlgeschlagen: HTTP Status {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "errorVideoPlayerFailed": "Video konnte nicht wiedergegeben werden.", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "serverUrlValidationErrorEmpty": "Bitte gib eine URL ein.", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "messageNotSentLabel": "NACHRICHT NICHT GESENDET", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "mutedUser": "Stummgeschaltete:r Nutzer:in", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "aboutPageTapToView": "Antippen zum Ansehen", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "tryAnotherAccountMessage": "Dein Account bei {url} benötigt einige Zeit zum Laden.", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + }, + "tryAnotherAccountButton": "Anderen Account ausprobieren", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, + "chooseAccountPageLogOutButton": "Abmelden", + "@chooseAccountPageLogOutButton": { + "description": "Label for the 'Log out' button for an account on the choose-account page" + }, + "logOutConfirmationDialogTitle": "Abmelden?", + "@logOutConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for logging out." + }, + "logOutConfirmationDialogConfirmButton": "Abmelden", + "@logOutConfirmationDialogConfirmButton": { + "description": "Label for the 'Log out' button on a confirmation dialog for logging out." + }, + "chooseAccountButtonAddAnAccount": "Account hinzufügen", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "profileButtonSendDirectMessage": "Direktnachricht senden", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, + "permissionsNeededTitle": "Berechtigungen erforderlich", + "@permissionsNeededTitle": { + "description": "Title for dialog asking the user to grant additional permissions." + }, + "errorCouldNotShowUserProfile": "Nutzerprofil kann nicht angezeigt werden.", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "permissionsNeededOpenSettings": "Einstellungen öffnen", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." + }, + "permissionsDeniedCameraAccess": "Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um ein Bild hochzuladen.", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "actionSheetOptionUnfollowTopic": "Thema entfolgen", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "permissionsDeniedReadExternalStorage": "Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um Dateien hochzuladen.", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "actionSheetOptionMarkChannelAsRead": "Kanal als gelesen markieren", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionMuteTopic": "Thema stummschalten", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "Thema lautschalten", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionFollowTopic": "Thema folgen", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "actionSheetOptionResolveTopic": "Als gelöst markieren", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic action sheet." + }, + "actionSheetOptionShare": "Teilen", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "errorWebAuthOperationalErrorTitle": "Etwas ist schiefgelaufen", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorWebAuthOperationalError": "Ein unerwarteter Fehler ist aufgetreten.", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorFilesTooLarge": "{num, plural, =1{Datei ist} other{{num} Dateien sind}} größer als das Serverlimit von {maxFileUploadSizeMib} MiB und {num, plural, =1{wird} other{{num} werden}} nicht hochgeladen:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "2" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB" + } + } + }, + "errorFilesTooLargeTitle": "{num, plural, =1{Datei} other{Dateien}} zu groß", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorLoginInvalidInputTitle": "Ungültige Eingabe", + "@errorLoginInvalidInputTitle": { + "description": "Error title for login when input is invalid." + }, + "errorLoginCouldNotConnect": "Verbindung zu Server fehlgeschlagen:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorCouldNotConnectTitle": "Konnte nicht verbinden", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorMessageDoesNotSeemToExist": "Diese Nachricht scheint nicht zu existieren.", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "errorQuotationFailed": "Zitat fehlgeschlagen", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "errorServerMessage": "Der Server sagte:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerDetails": "Fehler beim Verbinden mit Zulip auf {serverUrl}. Wird wiederholt:\n\n{error}", + "@errorConnectingToServerDetails": { + "description": "Dialog error message for a generic unknown error connecting to the server with details.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "http://example.com/" + }, + "error": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerShort": "Fehler beim Verbinden mit Zulip. Wiederhole…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorHandlingEventTitle": "Fehler beim Verarbeiten eines Zulip-Ereignisses. Wiederhole Verbindung…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "errorCouldNotOpenLinkTitle": "Link kann nicht geöffnet werden", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorUnfollowTopicFailed": "Konnte Thema nicht entfolgen", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorFollowTopicFailed": "Konnte Thema nicht folgen", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorStarMessageFailedTitle": "Konnte Nachricht nicht markieren", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnmuteTopicFailed": "Konnte Thema nicht lautschalten", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorSharingFailed": "Teilen fehlgeschlagen", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "errorUnstarMessageFailedTitle": "Konnte Markierung nicht von der Nachricht entfernen", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "successLinkCopied": "Link kopiert", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "successMessageTextCopied": "Nachrichtentext kopiert", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "successMessageLinkCopied": "Nachrichtenlink kopiert", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "errorBannerDeactivatedDmLabel": "Du kannst keine Nachrichten an deaktivierte Nutzer:innen senden.", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "errorBannerCannotPostInChannelLabel": "Du hast keine Berechtigung in diesen Kanal zu schreiben.", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "composeBoxAttachFilesTooltip": "Dateien anhängen", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "composeBoxAttachMediaTooltip": "Bilder oder Videos anhängen", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "composeBoxAttachFromCameraTooltip": "Ein Foto aufnehmen", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxSelfDmContentHint": "Schreibe etwas", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxDmContentHint": "Nachricht an @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "composeBoxGroupDmContentHint": "Nachricht an Gruppe", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxChannelContentHint": "Nachricht an {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "composeBoxSendTooltip": "Senden", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "composeBoxUploadingFilename": "Lade {filename} hoch…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "composeBoxLoadingMessage": "(lade Nachricht {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "dmsWithOthersPageTitle": "DNs mit {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "messageListGroupYouWithYourself": "Nachrichten mit dir selbst", + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "contentValidationErrorQuoteAndReplyInProgress": "Bitte warte bis das Zitat abgeschlossen ist.", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "errorDialogContinue": "OK", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "errorDialogTitle": "Fehler", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "loginFormSubmitLabel": "Anmelden", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "lightboxVideoDuration": "Videolänge", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "loginPageTitle": "Anmelden", + "@loginPageTitle": { + "description": "Title for login page." + }, + "signInWithFoo": "Anmelden mit {method}", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "loginAddAnAccountPageTitle": "Account hinzufügen", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "loginPasswordLabel": "Passwort", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginUsernameLabel": "Benutzername", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "loginErrorMissingUsername": "Bitte gib deinen Benutzernamen ein.", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "errorServerVersionUnsupportedMessage": "{url} nutzt Zulip Server {zulipVersion}, welche nicht unterstützt wird. Die unterstützte Mindestversion ist Zulip Server {minSupportedZulipVersion}.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + }, + "zulipVersion": { + "type": "String", + "example": "3.2" + }, + "minSupportedZulipVersion": { + "type": "String", + "example": "4.0" + } + } + }, + "topicValidationErrorMandatoryButEmpty": "Themen sind in dieser Organisation erforderlich.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "errorMalformedResponse": "Server lieferte fehlerhafte Antwort; HTTP Status {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + } + } + }, + "errorInvalidApiKeyMessage": "Dein Account bei {url} konnte nicht authentifiziert werden. Bitte wiederhole die Anmeldung oder verwende einen anderen Account.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorInvalidResponse": "Der Server hat eine ungültige Antwort gesendet.", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "errorNetworkRequestFailed": "Netzwerkanfrage fehlgeschlagen", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "serverUrlValidationErrorNoUseEmail": "Bitte gib die Server-URL ein, nicht deine E-Mail-Adresse.", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "serverUrlValidationErrorUnsupportedScheme": "Die Server-URL muss mit http:// oder https:// beginnen.", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "markAsReadInProgress": "Nachrichten werden als gelesen markiert…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "today": "Heute", + "@today": { + "description": "Term to use to reference the current day." + }, + "markAsUnreadInProgress": "Nachrichten werden als ungelesen markiert…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "Als ungelesen markieren fehlgeschlagen", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "yesterday": "Gestern", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "inboxPageTitle": "Eingang", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "recentDmConversationsPageTitle": "Direktnachrichten", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "combinedFeedPageTitle": "Kombinierter Feed", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "mentionsPageTitle": "Erwähnungen", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "mainMenuMyProfile": "Mein Profil", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "channelFeedButtonTooltip": "Kanal-Feed", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "notifGroupDmConversationLabel": "{senderFullName} an dich und {numOthers, plural, =1{1 weitere:n} other{{numOthers} weitere}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "notifSelfUser": "Du", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "reactedEmojiSelfUser": "Du", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "twoPeopleTyping": "{typist} und {otherTypist} tippen…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + }, + "otherTypist": { + "type": "String", + "example": "Bob" + } + } + }, + "manyPeopleTyping": "Mehrere Leute tippen…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "wildcardMentionAll": "alle", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionEveryone": "jeder", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionChannel": "Kanal", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionStream": "Stream", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "wildcardMentionTopic": "Thema", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionAllDmDescription": "Empfänger benachrichtigen", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "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" + } + } + }, + "messageIsMovedLabel": "VERSCHOBEN", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingDark": "Dunkel", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingLight": "Hell", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "themeSettingSystem": "System", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "openLinksWithInAppBrowser": "Links mit In-App-Browser öffnen", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "pollWidgetOptionsMissing": "Diese Umfrage hat noch keine Optionen.", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "pollWidgetQuestionMissing": "Keine Frage.", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "experimentalFeatureSettingsPageTitle": "Experimentelle Funktionen", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "errorNotificationOpenTitle": "Fehler beim Öffnen der Benachrichtigung", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "errorReactionRemovingFailedTitle": "Entfernen der Reaktion fehlgeschlagen", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "emojiReactionsMore": "mehr", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "scrollToBottomTooltip": "Nach unten Scrollen", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "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" + } + } + }, + "errorFailedToUploadFileTitle": "Fehler beim Upload der Datei: {filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when the specified file failed to upload.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "dmsWithYourselfPageTitle": "DNs mit dir selbst", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "noEarlierMessages": "Keine früheren Nachrichten", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "emojiPickerSearchEmoji": "Emoji suchen", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "mutedSender": "Stummgeschalteter Absender", + "@mutedSender": { + "description": "Name for a muted user to display in message list." } } diff --git a/assets/l10n/app_it.arb b/assets/l10n/app_it.arb index d417244e91..8cf9473078 100644 --- a/assets/l10n/app_it.arb +++ b/assets/l10n/app_it.arb @@ -193,7 +193,7 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionUnstarMessage": "Togli la stella dal messaggio", + "actionSheetOptionUnstarMessage": "Messaggio normale", "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, @@ -247,7 +247,7 @@ "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." }, - "actionSheetOptionStarMessage": "Metti una stella al messaggio", + "actionSheetOptionStarMessage": "Messaggio speciale", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." }, @@ -286,5 +286,899 @@ "example": "Invalid format" } } + }, + "errorCouldNotOpenLinkTitle": "Impossibile aprire il collegamento", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "errorMuteTopicFailed": "Impossibile silenziare l'argomento", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorFollowTopicFailed": "Impossibile seguire l'argomento", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorUnfollowTopicFailed": "Impossibile smettere di seguire l'argomento", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorSharingFailed": "Condivisione fallita", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "errorStarMessageFailedTitle": "Impossibile contrassegnare il messaggio come speciale", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnmuteTopicFailed": "Impossibile de-silenziare l'argomento", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "actionSheetOptionQuoteMessage": "Cita messaggio", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "errorCouldNotEditMessageTitle": "Impossibile modificare il messaggio", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "errorUnstarMessageFailedTitle": "Impossibile contrassegnare il messaggio come normale", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "errorCouldNotOpenLink": "Impossibile aprire il collegamento: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "successLinkCopied": "Collegamento copiato", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "errorHandlingEventDetails": "Errore nella gestione di un evento Zulip da {serverUrl}; verrà effettuato un nuovo tentativo.\n\nErrore: {error}\n\nEvento: {event}", + "@errorHandlingEventDetails": { + "description": "Error details on failing to handle a Zulip server event.", + "placeholders": { + "serverUrl": { + "type": "String", + "example": "https://chat.example.com" + }, + "error": { + "type": "String", + "example": "Unexpected null value" + }, + "event": { + "type": "String", + "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')" + } + } + }, + "successMessageLinkCopied": "Collegamento messaggio copiato", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "serverUrlValidationErrorUnsupportedScheme": "L'URL del server deve iniziare con http:// o https://.", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "recentDmConversationsEmptyPlaceholder": "Non ci sono ancora messaggi diretti! Perché non iniziare la conversazione?", + "@recentDmConversationsEmptyPlaceholder": { + "description": "Centered text on the 'Direct messages' page saying that there is no content to show." + }, + "errorBannerDeactivatedDmLabel": "Non è possibile inviare messaggi agli utenti disattivati.", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "starredMessagesPageTitle": "Messaggi speciali", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "successMessageTextCopied": "Testo messaggio copiato", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "composeBoxBannerButtonSave": "Salva", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "editAlreadyInProgressTitle": "Impossibile modificare il messaggio", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "editAlreadyInProgressMessage": "Una modifica è già in corso. Attendere il completamento.", + "@editAlreadyInProgressMessage": { + "description": "Error message when a message edit cannot be saved because there is another edit already in progress." + }, + "savingMessageEditLabel": "SALVATAGGIO MODIFICA…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "MODIFICA NON SALVATA", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Scartare il messaggio che si sta scrivendo?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxAttachFromCameraTooltip": "Fai una foto", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxGenericContentHint": "Batti un messaggio", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "newDmSheetComposeButtonLabel": "Componi", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetScreenTitle": "Nuovo MD", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmSheetSearchHintEmpty": "Aggiungi uno o più utenti", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "newDmSheetNoUsersFound": "Nessun utente trovato", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "composeBoxDmContentHint": "Messaggia @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "newDmFabButtonLabel": "Nuovo MD", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "composeBoxSelfDmContentHint": "Annota qualcosa", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxLoadingMessage": "(caricamento messaggio {messageId})", + "@composeBoxLoadingMessage": { + "description": "Placeholder in compose box showing the quoted message is currently loading.", + "placeholders": { + "messageId": { + "type": "int", + "example": "1234" + } + } + }, + "messageListGroupYouAndOthers": "Tu e {others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "dmsWithYourselfPageTitle": "MD con te stesso", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "dmsWithOthersPageTitle": "MD con {others}", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "contentValidationErrorQuoteAndReplyInProgress": "Attendere il completamento del commento.", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "errorDialogLearnMore": "Scopri di più", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "lightboxCopyLinkTooltip": "Copia collegamento", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "loginFormSubmitLabel": "Accesso", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "loginServerUrlLabel": "URL del server Zulip", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginHidePassword": "Nascondi password", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "errorMalformedResponse": "Il server ha fornito una risposta non valida; stato HTTP {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + } + } + }, + "errorRequestFailed": "Richiesta di rete non riuscita: stato HTTP {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "serverUrlValidationErrorInvalidUrl": "Inserire un URL valido.", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "markAsReadInProgress": "Contrassegno dei messaggi come letti…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "errorMarkAsReadFailedTitle": "Contrassegno come letto non riuscito", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "markAsUnreadInProgress": "Contrassegno dei messaggi come non letti…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "Contrassegno come non letti non riuscito", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "userRoleOwner": "Proprietario", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleModerator": "Moderatore", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "userRoleMember": "Membro", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "userRoleGuest": "Ospite", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "userRoleUnknown": "Sconosciuto", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "recentDmConversationsPageTitle": "Messaggi diretti", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "recentDmConversationsSectionHeader": "Messaggi diretti", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "channelsPageTitle": "Canali", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "channelFeedButtonTooltip": "Feed del canale", + "@channelFeedButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's feed" + }, + "twoPeopleTyping": "{typist} e {otherTypist} stanno scrivendo…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + }, + "otherTypist": { + "type": "String", + "example": "Bob" + } + } + }, + "manyPeopleTyping": "Molte persone stanno scrivendo…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." + }, + "wildcardMentionEveryone": "ognuno", + "@wildcardMentionEveryone": { + "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "wildcardMentionStream": "flusso", + "@wildcardMentionStream": { + "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers." + }, + "messageIsEditedLabel": "MODIFICATO", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingDark": "Scuro", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingLight": "Chiaro", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "openLinksWithInAppBrowser": "Apri i collegamenti con il browser in-app", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "pollWidgetOptionsMissing": "Questo sondaggio non ha ancora opzioni.", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "errorNotificationOpenAccountNotFound": "Impossibile trovare l'account associato a questa notifica.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "initialAnchorSettingTitle": "Apri i feed dei messaggi su", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "È possibile scegliere se i feed dei messaggi devono aprirsi al primo messaggio non letto oppure ai messaggi più recenti.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "Primo messaggio non letto", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "discardDraftForOutboxConfirmationDialogMessage": "Quando si recupera un messaggio non inviato, il contenuto precedentemente presente nella casella di composizione viene ignorato.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "markReadOnScrollSettingAlways": "Sempre", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "errorNotificationOpenTitle": "Impossibile aprire la notifica", + "@errorNotificationOpenTitle": { + "description": "Error title when notification opening fails" + }, + "errorReactionAddingFailedTitle": "Aggiunta della reazione non riuscita", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "Rimozione della reazione non riuscita", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "emojiReactionsMore": "altro", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "experimentalFeatureSettingsWarning": "Queste opzioni abilitano funzionalità ancora in fase di sviluppo e non ancora pronte. Potrebbero non funzionare e causare problemi in altre aree dell'app.\n\nQueste impostazioni sono pensate per la sperimentazione da parte di chi lavora allo sviluppo di Zulip.", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "signInWithFoo": "Accedi con {method}", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "discardDraftForEditConfirmationDialogMessage": "Quando si modifica un messaggio, il contenuto precedentemente presente nella casella di composizione viene ignorato.", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "lightboxVideoCurrentPosition": "Posizione corrente", + "@lightboxVideoCurrentPosition": { + "description": "The current playback position of the video playing in the lightbox." + }, + "loginAddAnAccountPageTitle": "Aggiungi account", + "@loginAddAnAccountPageTitle": { + "description": "Title for page to add a Zulip account." + }, + "errorInvalidResponse": "Il server ha inviato una risposta non valida.", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, + "serverUrlValidationErrorEmpty": "Inserire un URL.", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "snackBarDetails": "Dettagli", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "composeBoxTopicHintText": "Argomento", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Abbandona", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, + "composeBoxAttachFilesTooltip": "Allega file", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "errorDialogTitle": "Errore", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "composeBoxAttachMediaTooltip": "Allega immagini o video", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "unknownUserName": "(utente sconosciuto)", + "@unknownUserName": { + "description": "Name placeholder to use for a user when we don't know their name." + }, + "newDmSheetSearchHintSomeSelected": "Aggiungi un altro utente…", + "@newDmSheetSearchHintSomeSelected": { + "description": "Hint text for the search bar when at least one user is selected." + }, + "composeBoxGroupDmContentHint": "Gruppo di messaggi", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxChannelContentHint": "Messaggia {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "preparingEditMessageContentInput": "Preparazione…", + "@preparingEditMessageContentInput": { + "description": "Hint text for content input when the compose box is preparing to edit a message." + }, + "composeBoxSendTooltip": "Invia", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "unknownChannelName": "(canale sconosciuto)", + "@unknownChannelName": { + "description": "Replacement name for channel when it cannot be found in the store." + }, + "composeBoxUploadingFilename": "Caricamento {filename}…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "messageListGroupYouWithYourself": "Messaggi con te stesso", + "@messageListGroupYouWithYourself": { + "description": "Message list recipient header for a DM group that only includes yourself." + }, + "composeBoxEnterTopicOrSkipHintText": "Inserisci un argomento (salta per \"{defaultTopicName}\")", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "loginErrorMissingEmail": "Inserire l'email.", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "dialogContinue": "Continua", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "contentValidationErrorTooLong": "La lunghezza del messaggio non deve essere superiore a 10.000 caratteri.", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "loginErrorMissingPassword": "Inserire la propria password.", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "loginEmailLabel": "Indirizzo email", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "pollWidgetQuestionMissing": "Nessuna domanda.", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "contentValidationErrorEmpty": "Non devi inviare nulla!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "loginPageTitle": "Accesso", + "@loginPageTitle": { + "description": "Title for login page." + }, + "contentValidationErrorUploadInProgress": "Attendere il completamento del caricamento.", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "dialogCancel": "Annulla", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "errorDialogContinue": "Ok", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "dialogClose": "Chiudi", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "combinedFeedPageTitle": "Feed combinato", + "@combinedFeedPageTitle": { + "description": "Page title for the 'Combined feed' message view." + }, + "lightboxVideoDuration": "Durata video", + "@lightboxVideoDuration": { + "description": "The total duration of the video playing in the lightbox." + }, + "loginMethodDivider": "O", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "loginUsernameLabel": "Nomeutente", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "loginPasswordLabel": "Password", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginErrorMissingUsername": "Inserire il proprio nomeutente.", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "notifSelfUser": "Tu", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "topicValidationErrorTooLong": "La lunghezza dell'argomento non deve superare i 60 caratteri.", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "today": "Oggi", + "@today": { + "description": "Term to use to reference the current day." + }, + "topicValidationErrorMandatoryButEmpty": "In questa organizzazione sono richiesti degli argomenti.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "markAllAsReadLabel": "Segna tutti i messaggi come letti", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "errorInvalidApiKeyMessage": "L'account su {url} non è stato autenticato. Riprovare ad accedere o provare a usare un altro account.", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "errorNetworkRequestFailed": "Richiesta di rete non riuscita", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "errorMalformedResponseWithCause": "Il server ha fornito una risposta non valida; stato HTTP {httpStatus}; {details}", + "@errorMalformedResponseWithCause": { + "description": "Error message when an API call fails because we could not parse the response, with details of the failure.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "200" + }, + "details": { + "type": "String", + "example": "type 'Null' is not a subtype of type 'String' in type cast" + } + } + }, + "wildcardMentionAll": "tutti", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message." + }, + "channelsEmptyPlaceholder": "Non sei ancora iscritto ad alcun canale.", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "inboxPageTitle": "Inbox", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, + "errorVideoPlayerFailed": "Impossibile riprodurre il video.", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "serverUrlValidationErrorNoUseEmail": "Inserire l'URL del server, non il proprio indirizzo email.", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "userRoleAdministrator": "Amministratore", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "yesterday": "Ieri", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "themeSettingSystem": "Sistema", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "inboxEmptyPlaceholder": "Non ci sono messaggi non letti nella posta in arrivo. Usare i pulsanti sotto per visualizzare il feed combinato o l'elenco dei canali.", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' page saying that there is no content to show." + }, + "mentionsPageTitle": "Menzioni", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "mainMenuMyProfile": "Il mio profilo", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "topicsButtonLabel": "ARGOMENTI", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "reactedEmojiSelfUser": "Tu", + "@reactedEmojiSelfUser": { + "description": "Display name for the user themself, to show on an emoji reaction added by the user." + }, + "onePersonTyping": "{typist} sta scrivendo…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "wildcardMentionChannel": "canale", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "wildcardMentionTopic": "argomento", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "messageIsMovedLabel": "SPOSTATO", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageNotSentLabel": "MESSAGGIO NON INVIATO", + "@messageNotSentLabel": { + "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "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" + } + } + }, + "themeSettingTitle": "TEMA", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "composeBoxBannerLabelEditMessage": "Modifica messaggio", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "markReadOnScrollSettingTitle": "Segna i messaggi come letti durante lo scorrimento", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "composeBoxBannerButtonCancel": "Annulla", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "initialAnchorSettingFirstUnreadConversations": "Primo messaggio non letto nelle singole conversazioni, messaggio più recente altrove", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingConversations": "Solo nelle visualizzazioni delle conversazioni", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "experimentalFeatureSettingsPageTitle": "Caratteristiche sperimentali", + "@experimentalFeatureSettingsPageTitle": { + "description": "Title of settings page for experimental, in-development features" + }, + "errorBannerCannotPostInChannelLabel": "Non hai l'autorizzazione per postare su questo canale.", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "initialAnchorSettingNewestAlways": "Messaggio più recente", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingNever": "Mai", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "Quando si scorrono i messaggi, questi devono essere contrassegnati automaticamente come letti?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "I messaggi verranno automaticamente contrassegnati come in sola lettura quando si visualizza un singolo argomento o una conversazione in un messaggio diretto.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "errorFilesTooLargeTitle": "{num, plural, =1{File} other{File}} troppo grande/i", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "spoilerDefaultHeaderText": "Spoiler", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAsUnreadComplete": "Segnato/i {num, plural, =1{1 messaggio} other{{num} messagi}} come non letto/i.", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "pinnedSubscriptionsLabel": "Bloccato", + "@pinnedSubscriptionsLabel": { + "description": "Label for the list of pinned subscribed channels." + }, + "unpinnedSubscriptionsLabel": "Non bloccato", + "@unpinnedSubscriptionsLabel": { + "description": "Label for the list of unpinned subscribed channels." + }, + "wildcardMentionStreamDescription": "Notifica flusso", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionAllDmDescription": "Notifica destinatari", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "wildcardMentionTopicDescription": "Notifica argomento", + "@wildcardMentionTopicDescription": { + "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "errorFilesTooLarge": "{num, plural, =1{file è} other{{num} file sono}} più grande/i del limite del server di {maxFileUploadSizeMib} MiB e non verrà/anno caricato/i:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "2" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt: 10.1 MiB\nbar.txt 20.2 MiB" + } + } + }, + "noEarlierMessages": "Nessun messaggio precedente", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "mutedSender": "Mittente silenziato", + "@mutedSender": { + "description": "Name for a muted user to display in message list." + }, + "revealButtonLabel": "Mostra messaggio per mittente silenziato", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "mutedUser": "Utente silenziato", + "@mutedUser": { + "description": "Name for a muted user to display all over the app." + }, + "scrollToBottomTooltip": "Scorri fino in fondo", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "markAsReadComplete": "Segnato/i {num, plural, =1{1 messaggio} other{{num} messagei}} come letto/i.", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorServerVersionUnsupportedMessage": "{url} sta usando Zulip Server {zulipVersion}, che non è supportato. La versione minima supportata è Zulip Server {minSupportedZulipVersion}.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + }, + "zulipVersion": { + "type": "String", + "example": "3.2" + }, + "minSupportedZulipVersion": { + "type": "String", + "example": "4.0" + } + } + }, + "wildcardMentionChannelDescription": "Notifica canale", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "notifGroupDmConversationLabel": "{senderFullName} a te e {numOthers, plural, =1{1 altro} other{{numOthers} altri}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "emojiPickerSearchEmoji": "Cerca emoji", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." } } diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index acc8644b3d..168ede020a 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1152,5 +1152,33 @@ "initialAnchorSettingNewestAlways": "Najnowsza wiadomość", "@initialAnchorSettingNewestAlways": { "description": "Label for a value of setting controlling initial anchor of message list." + }, + "actionSheetOptionQuoteMessage": "Cytuj wiadomość", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "markReadOnScrollSettingTitle": "Oznacz wiadomości jako przeczytane przy przwijaniu", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Zawsze", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Tylko w widoku dyskusji", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Nigdy", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "Czy chcesz z automatu oznaczać wiadomości jako przeczytane przy przewijaniu?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "Wiadomości zostaną z automatu oznaczone jako przeczytane tylko w pojedyczym wątku lub w wymianie wiadomości bezpośrednich.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index a9707ff7a9..465f339f0a 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1152,5 +1152,33 @@ "initialAnchorSettingFirstUnreadConversations": "Первое непрочитанное сообщение в личных беседах, самое новое в остальных", "@initialAnchorSettingFirstUnreadConversations": { "description": "Label for a value of setting controlling initial anchor of message list." + }, + "actionSheetOptionQuoteMessage": "Цитировать сообщение", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "markReadOnScrollSettingTitle": "Отмечать сообщения как прочитанные при прокрутке", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "При прокрутке сообщений автоматически отмечать их как прочитанные?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Только при просмотре бесед", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Никогда", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Всегда", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "Сообщения будут автоматически помечаться как прочитанные только при просмотре отдельной темы или личной беседы.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." } } diff --git a/assets/l10n/app_zh_Hans_CN.arb b/assets/l10n/app_zh_Hans_CN.arb index 5e5c347f44..db89285899 100644 --- a/assets/l10n/app_zh_Hans_CN.arb +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -5,7 +5,7 @@ "@actionSheetOptionResolveTopic": { "description": "Label for the 'Mark as resolved' button on the topic action sheet." }, - "aboutPageTitle": "关于Zulip", + "aboutPageTitle": "关于 Zulip", "@aboutPageTitle": { "description": "Title for About Zulip page." }, @@ -325,7 +325,7 @@ "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." }, - "notifGroupDmConversationLabel": "{senderFullName}向你和其他 {numOthers, plural, other{{numOthers} 个用户}}", + "notifGroupDmConversationLabel": "{senderFullName}向您和其他 {numOthers, plural, other{{numOthers} 个用户}}", "@notifGroupDmConversationLabel": { "description": "Label for a group DM conversation notification.", "placeholders": { @@ -367,7 +367,7 @@ "@messageIsEditedLabel": { "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, - "themeSettingDark": "深色", + "themeSettingDark": "暗色", "@themeSettingDark": { "description": "Label for dark theme setting." }, @@ -389,7 +389,7 @@ "@pollWidgetOptionsMissing": { "description": "Text to display for a poll when it has no options" }, - "experimentalFeatureSettingsWarning": "以下选项启用了一些正在开发中的功能。它们可能不能正常使用,或造成一些其他的问题。\n\n这些选项能够帮助开发者更好的试验这些功能。", + "experimentalFeatureSettingsWarning": "以下选项能够启用开发中的功能。它们暂不完善,并可能造成其他的一些问题。\n\n这些选项的目的是为了帮助开发者进行实验。", "@experimentalFeatureSettingsWarning": { "description": "Warning text on settings page for experimental, in-development features" }, @@ -553,7 +553,7 @@ "@openLinksWithInAppBrowser": { "description": "Label for toggling setting to open links with in-app browser" }, - "inboxEmptyPlaceholder": "你的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。", + "inboxEmptyPlaceholder": "您的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。", "@inboxEmptyPlaceholder": { "description": "Centered text on the 'Inbox' page saying that there is no content to show." }, @@ -625,7 +625,7 @@ "@discardDraftConfirmationDialogConfirmButton": { "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." }, - "composeBoxGroupDmContentHint": "私信群组", + "composeBoxGroupDmContentHint": "发送私信到群组", "@composeBoxGroupDmContentHint": { "description": "Hint text for content input when sending a message to a group." }, @@ -975,7 +975,7 @@ "@newDmSheetNoUsersFound": { "description": "Message shown in the new DM sheet when no users match the search." }, - "composeBoxDmContentHint": "私信 @{user}", + "composeBoxDmContentHint": "发送私信给 @{user}", "@composeBoxDmContentHint": { "description": "Hint text for content input when sending a message to one other person.", "placeholders": { @@ -1071,7 +1071,7 @@ "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." }, - "mentionsPageTitle": "@提及", + "mentionsPageTitle": "被提及消息", "@mentionsPageTitle": { "description": "Page title for the 'Mentions' message view." }, @@ -1150,5 +1150,33 @@ "pollWidgetQuestionMissing": "无问题。", "@pollWidgetQuestionMissing": { "description": "Text to display for a poll when the question is missing" + }, + "markReadOnScrollSettingAlways": "总是", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "从不", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "只在对话视图", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingDescription": "在滑动浏览消息时,是否自动将它们标记为已读?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversationsDescription": "只将在同一个话题或私聊中的消息自动标记为已读。", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingTitle": "滑动时将消息标为已读", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "actionSheetOptionQuoteMessage": "引用消息", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." } } diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 43dfedc1d5..2fbaa4b5b5 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -18,7 +18,7 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get aboutPageOpenSourceLicenses => 'Open-Source-Lizenzen'; @override - String get aboutPageTapToView => 'Tap to view'; + String get aboutPageTapToView => 'Antippen zum Ansehen'; @override String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; @@ -45,133 +45,138 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String tryAnotherAccountMessage(Object url) { - return 'Your account at $url is taking a while to load.'; + return 'Dein Account bei $url benötigt einige Zeit zum Laden.'; } @override - String get tryAnotherAccountButton => 'Try another account'; + String get tryAnotherAccountButton => 'Anderen Account ausprobieren'; @override - String get chooseAccountPageLogOutButton => 'Log out'; + String get chooseAccountPageLogOutButton => 'Abmelden'; @override - String get logOutConfirmationDialogTitle => 'Log out?'; + String get logOutConfirmationDialogTitle => 'Abmelden?'; @override String get logOutConfirmationDialogMessage => - 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.'; + 'Um diesen Account in Zukunft zu verwenden, musst du die URL deiner Organisation und deine Account-Informationen erneut eingeben.'; @override - String get logOutConfirmationDialogConfirmButton => 'Log out'; + String get logOutConfirmationDialogConfirmButton => 'Abmelden'; @override - String get chooseAccountButtonAddAnAccount => 'Add an account'; + String get chooseAccountButtonAddAnAccount => 'Account hinzufügen'; @override - String get profileButtonSendDirectMessage => 'Send direct message'; + String get profileButtonSendDirectMessage => 'Direktnachricht senden'; @override - String get errorCouldNotShowUserProfile => 'Could not show user profile.'; + String get errorCouldNotShowUserProfile => + 'Nutzerprofil kann nicht angezeigt werden.'; @override - String get permissionsNeededTitle => 'Permissions needed'; + String get permissionsNeededTitle => 'Berechtigungen erforderlich'; @override - String get permissionsNeededOpenSettings => 'Open settings'; + String get permissionsNeededOpenSettings => 'Einstellungen öffnen'; @override String get permissionsDeniedCameraAccess => - 'To upload an image, please grant Zulip additional permissions in Settings.'; + 'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um ein Bild hochzuladen.'; @override String get permissionsDeniedReadExternalStorage => - 'To upload files, please grant Zulip additional permissions in Settings.'; + 'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um Dateien hochzuladen.'; @override - String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + String get actionSheetOptionMarkChannelAsRead => + 'Kanal als gelesen markieren'; @override - String get actionSheetOptionListOfTopics => 'List of topics'; + String get actionSheetOptionListOfTopics => 'Themenliste'; @override - String get actionSheetOptionMuteTopic => 'Mute topic'; + String get actionSheetOptionMuteTopic => 'Thema stummschalten'; @override - String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + String get actionSheetOptionUnmuteTopic => 'Thema lautschalten'; @override - String get actionSheetOptionFollowTopic => 'Follow topic'; + String get actionSheetOptionFollowTopic => 'Thema folgen'; @override - String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + String get actionSheetOptionUnfollowTopic => 'Thema entfolgen'; @override - String get actionSheetOptionResolveTopic => 'Mark as resolved'; + String get actionSheetOptionResolveTopic => 'Als gelöst markieren'; @override - String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + String get actionSheetOptionUnresolveTopic => 'Als ungelöst markieren'; @override - String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + String get errorResolveTopicFailedTitle => + 'Thema konnte nicht als gelöst markiert werden'; @override String get errorUnresolveTopicFailedTitle => - 'Failed to mark topic as unresolved'; + 'Thema konnte nicht als ungelöst markiert werden'; @override - String get actionSheetOptionCopyMessageText => 'Copy message text'; + String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; @override - String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + String get actionSheetOptionCopyMessageLink => 'Link zur Nachricht kopieren'; @override - String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + String get actionSheetOptionMarkAsUnread => 'Ab hier als ungelesen markieren'; @override - String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + String get actionSheetOptionHideMutedMessage => + 'Stummgeschaltete Nachricht wieder ausblenden'; @override - String get actionSheetOptionShare => 'Share'; + String get actionSheetOptionShare => 'Teilen'; @override - String get actionSheetOptionQuoteMessage => 'Quote message'; + String get actionSheetOptionQuoteMessage => 'Nachricht zitieren'; @override - String get actionSheetOptionStarMessage => 'Star message'; + String get actionSheetOptionStarMessage => 'Nachricht markieren'; @override - String get actionSheetOptionUnstarMessage => 'Unstar message'; + String get actionSheetOptionUnstarMessage => 'Markierung aufheben'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'Nachricht bearbeiten'; @override - String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + String get actionSheetOptionMarkTopicAsRead => 'Thema als gelesen markieren'; @override - String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + String get errorWebAuthOperationalErrorTitle => 'Etwas ist schiefgelaufen'; @override - String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + String get errorWebAuthOperationalError => + 'Ein unerwarteter Fehler ist aufgetreten.'; @override - String get errorAccountLoggedInTitle => 'Account already logged in'; + String get errorAccountLoggedInTitle => 'Account bereits angemeldet'; @override String errorAccountLoggedIn(String email, String server) { - return 'The account $email at $server is already in your list of accounts.'; + return 'Der Account $email auf $server ist bereits in deiner Account-Liste.'; } @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source.'; + 'Konnte Nachrichtenquelle nicht abrufen.'; @override - String get errorCopyingFailed => 'Copying failed'; + String get errorCopyingFailed => 'Kopieren fehlgeschlagen'; @override String errorFailedToUploadFileTitle(String filename) { - return 'Failed to upload file: $filename'; + return 'Fehler beim Upload der Datei: $filename'; } @override @@ -188,10 +193,16 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num files are', - one: 'File is', + other: '$num Dateien sind', + one: 'Datei ist', ); - return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + String _temp1 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num werden', + one: 'wird', + ); + return '$_temp0 größer als das Serverlimit von $maxFileUploadSizeMib MiB und $_temp1 nicht hochgeladen:\n\n$listMessage'; } @override @@ -199,56 +210,56 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: 'Files', - one: 'File', + other: 'Dateien', + one: 'Datei', ); - return '$_temp0 too large'; + return '$_temp0 zu groß'; } @override - String get errorLoginInvalidInputTitle => 'Invalid input'; + String get errorLoginInvalidInputTitle => 'Ungültige Eingabe'; @override - String get errorLoginFailedTitle => 'Login failed'; + String get errorLoginFailedTitle => 'Anmeldung fehlgeschlagen'; @override - String get errorMessageNotSent => 'Message not sent'; + String get errorMessageNotSent => 'Nachricht nicht versendet'; @override - String get errorMessageEditNotSaved => 'Message not saved'; + String get errorMessageEditNotSaved => 'Nachricht nicht gespeichert'; @override String errorLoginCouldNotConnect(String url) { - return 'Failed to connect to server:\n$url'; + return 'Verbindung zu Server fehlgeschlagen:\n$url'; } @override - String get errorCouldNotConnectTitle => 'Could not connect'; + String get errorCouldNotConnectTitle => 'Konnte nicht verbinden'; @override String get errorMessageDoesNotSeemToExist => - 'That message does not seem to exist.'; + 'Diese Nachricht scheint nicht zu existieren.'; @override - String get errorQuotationFailed => 'Quotation failed'; + String get errorQuotationFailed => 'Zitat fehlgeschlagen'; @override String errorServerMessage(String message) { - return 'The server said:\n\n$message'; + return 'Der Server sagte:\n\n$message'; } @override String get errorConnectingToServerShort => - 'Error connecting to Zulip. Retrying…'; + 'Fehler beim Verbinden mit Zulip. Wiederhole…'; @override String errorConnectingToServerDetails(String serverUrl, String error) { - return 'Error connecting to Zulip at $serverUrl. Will retry:\n\n$error'; + return 'Fehler beim Verbinden mit Zulip auf $serverUrl. Wird wiederholt:\n\n$error'; } @override String get errorHandlingEventTitle => - 'Error handling a Zulip event. Retrying connection…'; + 'Fehler beim Verarbeiten eines Zulip-Ereignisses. Wiederhole Verbindung…'; @override String errorHandlingEventDetails( @@ -256,280 +267,284 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String error, String event, ) { - return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + return 'Fehler beim Verarbeiten eines Zulip-Ereignisses von $serverUrl; Wird wiederholt.\n\nFehler: $error\n\nEreignis: $event'; } @override - String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + String get errorCouldNotOpenLinkTitle => 'Link kann nicht geöffnet werden'; @override String errorCouldNotOpenLink(String url) { - return 'Link could not be opened: $url'; + return 'Link konnte nicht geöffnet werden: $url'; } @override - String get errorMuteTopicFailed => 'Failed to mute topic'; + String get errorMuteTopicFailed => 'Konnte Thema nicht stummschalten'; @override - String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + String get errorUnmuteTopicFailed => 'Konnte Thema nicht lautschalten'; @override - String get errorFollowTopicFailed => 'Failed to follow topic'; + String get errorFollowTopicFailed => 'Konnte Thema nicht folgen'; @override - String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + String get errorUnfollowTopicFailed => 'Konnte Thema nicht entfolgen'; @override - String get errorSharingFailed => 'Sharing failed'; + String get errorSharingFailed => 'Teilen fehlgeschlagen'; @override - String get errorStarMessageFailedTitle => 'Failed to star message'; + String get errorStarMessageFailedTitle => 'Konnte Nachricht nicht markieren'; @override - String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + String get errorUnstarMessageFailedTitle => + 'Konnte Markierung nicht von der Nachricht entfernen'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => + 'Konnte Nachricht nicht bearbeiten'; @override - String get successLinkCopied => 'Link copied'; + String get successLinkCopied => 'Link kopiert'; @override - String get successMessageTextCopied => 'Message text copied'; + String get successMessageTextCopied => 'Nachrichtentext kopiert'; @override - String get successMessageLinkCopied => 'Message link copied'; + String get successMessageLinkCopied => 'Nachrichtenlink kopiert'; @override String get errorBannerDeactivatedDmLabel => - 'You cannot send messages to deactivated users.'; + 'Du kannst keine Nachrichten an deaktivierte Nutzer:innen senden.'; @override String get errorBannerCannotPostInChannelLabel => - 'You do not have permission to post in this channel.'; + 'Du hast keine Berechtigung in diesen Kanal zu schreiben.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Nachricht bearbeiten'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Abbrechen'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Speichern'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => 'Kann Nachricht nicht bearbeiten'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Eine Bearbeitung läuft gerade. Bitte warte bis sie abgeschlossen ist.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'SPEICHERE BEARBEITUNG…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'BEARBEITUNG NICHT GESPEICHERT'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + 'Die Nachricht, die du schreibst, verwerfen?'; @override String get discardDraftForEditConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + 'Wenn du eine Nachricht bearbeitest, wird der vorherige Inhalt der Nachrichteneingabe verworfen.'; @override String get discardDraftForOutboxConfirmationDialogMessage => - 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + 'Wenn du eine nicht gesendete Nachricht wiederherstellst, wird der vorherige Inhalt der Nachrichteneingabe verworfen.'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get discardDraftConfirmationDialogConfirmButton => 'Verwerfen'; @override - String get composeBoxAttachFilesTooltip => 'Attach files'; + String get composeBoxAttachFilesTooltip => 'Dateien anhängen'; @override - String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + String get composeBoxAttachMediaTooltip => 'Bilder oder Videos anhängen'; @override - String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + String get composeBoxAttachFromCameraTooltip => 'Ein Foto aufnehmen'; @override - String get composeBoxGenericContentHint => 'Type a message'; + String get composeBoxGenericContentHint => 'Eine Nachricht eingeben'; @override - String get newDmSheetComposeButtonLabel => 'Compose'; + String get newDmSheetComposeButtonLabel => 'Verfassen'; @override - String get newDmSheetScreenTitle => 'New DM'; + String get newDmSheetScreenTitle => 'Neue DN'; @override - String get newDmFabButtonLabel => 'New DM'; + String get newDmFabButtonLabel => 'Neue DN'; @override - String get newDmSheetSearchHintEmpty => 'Add one or more users'; + String get newDmSheetSearchHintEmpty => + 'Füge ein oder mehrere Nutzer:innen hinzu'; @override - String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + String get newDmSheetSearchHintSomeSelected => + 'Füge weitere Nutzer:in hinzu…'; @override - String get newDmSheetNoUsersFound => 'No users found'; + String get newDmSheetNoUsersFound => 'Keine Nutzer:innen gefunden'; @override String composeBoxDmContentHint(String user) { - return 'Message @$user'; + return 'Nachricht an @$user'; } @override - String get composeBoxGroupDmContentHint => 'Message group'; + String get composeBoxGroupDmContentHint => 'Nachricht an Gruppe'; @override - String get composeBoxSelfDmContentHint => 'Jot down something'; + String get composeBoxSelfDmContentHint => 'Schreibe etwas'; @override String composeBoxChannelContentHint(String destination) { - return 'Message $destination'; + return 'Nachricht an $destination'; } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Bereite vor…'; @override - String get composeBoxSendTooltip => 'Send'; + String get composeBoxSendTooltip => 'Senden'; @override - String get unknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(unbekannter Kanal)'; @override - String get composeBoxTopicHintText => 'Topic'; + String get composeBoxTopicHintText => 'Thema'; @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Gib ein Thema ein (leer lassen für “$defaultTopicName”)'; } @override String composeBoxUploadingFilename(String filename) { - return 'Uploading $filename…'; + return 'Lade $filename hoch…'; } @override String composeBoxLoadingMessage(int messageId) { - return '(loading message $messageId)'; + return '(lade Nachricht $messageId)'; } @override - String get unknownUserName => '(unknown user)'; + String get unknownUserName => '(Nutzer:in unbekannt)'; @override - String get dmsWithYourselfPageTitle => 'DMs with yourself'; + String get dmsWithYourselfPageTitle => 'DNs mit dir selbst'; @override String messageListGroupYouAndOthers(String others) { - return 'You and $others'; + return 'Du und $others'; } @override String dmsWithOthersPageTitle(String others) { - return 'DMs with $others'; + return 'DNs mit $others'; } @override - String get messageListGroupYouWithYourself => 'Messages with yourself'; + String get messageListGroupYouWithYourself => 'Nachrichten mit dir selbst'; @override String get contentValidationErrorTooLong => - 'Message length shouldn\'t be greater than 10000 characters.'; + 'Nachrichtenlänge sollte nicht größer als 10000 Zeichen sein.'; @override - String get contentValidationErrorEmpty => 'You have nothing to send!'; + String get contentValidationErrorEmpty => 'Du hast nichts zum Senden!'; @override String get contentValidationErrorQuoteAndReplyInProgress => - 'Please wait for the quotation to complete.'; + 'Bitte warte bis das Zitat abgeschlossen ist.'; @override String get contentValidationErrorUploadInProgress => - 'Please wait for the upload to complete.'; + 'Bitte warte bis das Hochladen abgeschlossen ist.'; @override - String get dialogCancel => 'Cancel'; + String get dialogCancel => 'Abbrechen'; @override - String get dialogContinue => 'Continue'; + String get dialogContinue => 'Fortsetzen'; @override - String get dialogClose => 'Close'; + String get dialogClose => 'Schließen'; @override - String get errorDialogLearnMore => 'Learn more'; + String get errorDialogLearnMore => 'Mehr erfahren'; @override String get errorDialogContinue => 'OK'; @override - String get errorDialogTitle => 'Error'; + String get errorDialogTitle => 'Fehler'; @override String get snackBarDetails => 'Details'; @override - String get lightboxCopyLinkTooltip => 'Copy link'; + String get lightboxCopyLinkTooltip => 'Link kopieren'; @override - String get lightboxVideoCurrentPosition => 'Current position'; + String get lightboxVideoCurrentPosition => 'Aktuelle Position'; @override - String get lightboxVideoDuration => 'Video duration'; + String get lightboxVideoDuration => 'Videolänge'; @override - String get loginPageTitle => 'Log in'; + String get loginPageTitle => 'Anmelden'; @override - String get loginFormSubmitLabel => 'Log in'; + String get loginFormSubmitLabel => 'Anmelden'; @override - String get loginMethodDivider => 'OR'; + String get loginMethodDivider => 'ODER'; @override String signInWithFoo(String method) { - return 'Sign in with $method'; + return 'Anmelden mit $method'; } @override - String get loginAddAnAccountPageTitle => 'Add an account'; + String get loginAddAnAccountPageTitle => 'Account hinzufügen'; @override - String get loginServerUrlLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'Deine Zulip Server URL'; @override - String get loginHidePassword => 'Hide password'; + String get loginHidePassword => 'Passwort verstecken'; @override - String get loginEmailLabel => 'Email address'; + String get loginEmailLabel => 'E-Mail-Adresse'; @override - String get loginErrorMissingEmail => 'Please enter your email.'; + String get loginErrorMissingEmail => 'Bitte gib deine E-Mail ein.'; @override - String get loginPasswordLabel => 'Password'; + String get loginPasswordLabel => 'Passwort'; @override - String get loginErrorMissingPassword => 'Please enter your password.'; + String get loginErrorMissingPassword => 'Bitte gib dein Passwort ein.'; @override - String get loginUsernameLabel => 'Username'; + String get loginUsernameLabel => 'Benutzername'; @override - String get loginErrorMissingUsername => 'Please enter your username.'; + String get loginErrorMissingUsername => 'Bitte gib deinen Benutzernamen ein.'; @override String get topicValidationErrorTooLong => - 'Topic length shouldn\'t be greater than 60 characters.'; + 'Länge des Themas sollte 60 Zeichen nicht überschreiten.'; @override String get topicValidationErrorMandatoryButEmpty => - 'Topics are required in this organization.'; + 'Themen sind in dieser Organisation erforderlich.'; @override String errorServerVersionUnsupportedMessage( @@ -537,100 +552,106 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String zulipVersion, String minSupportedZulipVersion, ) { - return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + return '$url nutzt Zulip Server $zulipVersion, welche nicht unterstützt wird. Die unterstützte Mindestversion ist Zulip Server $minSupportedZulipVersion.'; } @override String errorInvalidApiKeyMessage(String url) { - return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + return 'Dein Account bei $url konnte nicht authentifiziert werden. Bitte wiederhole die Anmeldung oder verwende einen anderen Account.'; } @override - String get errorInvalidResponse => 'The server sent an invalid response.'; + String get errorInvalidResponse => + 'Der Server hat eine ungültige Antwort gesendet.'; @override - String get errorNetworkRequestFailed => 'Network request failed'; + String get errorNetworkRequestFailed => 'Netzwerkanfrage fehlgeschlagen'; @override String errorMalformedResponse(int httpStatus) { - return 'Server gave malformed response; HTTP status $httpStatus'; + return 'Server lieferte fehlerhafte Antwort; HTTP Status $httpStatus'; } @override String errorMalformedResponseWithCause(int httpStatus, String details) { - return 'Server gave malformed response; HTTP status $httpStatus; $details'; + return 'Server lieferte fehlerhafte Antwort; HTTP Status $httpStatus; $details'; } @override String errorRequestFailed(int httpStatus) { - return 'Network request failed: HTTP status $httpStatus'; + return 'Netzwerkanfrage fehlgeschlagen: HTTP Status $httpStatus'; } @override - String get errorVideoPlayerFailed => 'Unable to play the video.'; + String get errorVideoPlayerFailed => + 'Video konnte nicht wiedergegeben werden.'; @override - String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + String get serverUrlValidationErrorEmpty => 'Bitte gib eine URL ein.'; @override - String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + String get serverUrlValidationErrorInvalidUrl => + 'Bitte gib eine gültige URL ein.'; @override String get serverUrlValidationErrorNoUseEmail => - 'Please enter the server URL, not your email.'; + 'Bitte gib die Server-URL ein, nicht deine E-Mail-Adresse.'; @override String get serverUrlValidationErrorUnsupportedScheme => - 'The server URL must start with http:// or https://.'; + 'Die Server-URL muss mit http:// oder https:// beginnen.'; @override String get spoilerDefaultHeaderText => 'Spoiler'; @override - String get markAllAsReadLabel => 'Mark all messages as read'; + String get markAllAsReadLabel => 'Alle Nachrichten als gelesen markieren'; @override String markAsReadComplete(int num) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num messages', - one: '1 message', + other: '$num Nachrichten', + one: 'Eine Nachricht', ); - return 'Marked $_temp0 as read.'; + return '$_temp0 als gelesen markiert.'; } @override - String get markAsReadInProgress => 'Marking messages as read…'; + String get markAsReadInProgress => 'Nachrichten werden als gelesen markiert…'; @override - String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + String get errorMarkAsReadFailedTitle => + 'Als gelesen markieren fehlgeschlagen'; @override String markAsUnreadComplete(int num) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num messages', - one: '1 message', + other: '$num Nachrichten', + one: 'Eine Nachricht', ); - return 'Marked $_temp0 as unread.'; + return '$_temp0 als ungelesen markiert.'; } @override - String get markAsUnreadInProgress => 'Marking messages as unread…'; + String get markAsUnreadInProgress => + 'Nachrichten werden als ungelesen markiert…'; @override - String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + String get errorMarkAsUnreadFailedTitle => + 'Als ungelesen markieren fehlgeschlagen'; @override - String get today => 'Today'; + String get today => 'Heute'; @override - String get yesterday => 'Yesterday'; + String get yesterday => 'Gestern'; @override - String get userRoleOwner => 'Owner'; + String get userRoleOwner => 'Besitzer'; @override String get userRoleAdministrator => 'Administrator'; @@ -639,232 +660,239 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get userRoleModerator => 'Moderator'; @override - String get userRoleMember => 'Member'; + String get userRoleMember => 'Mitglied'; @override - String get userRoleGuest => 'Guest'; + String get userRoleGuest => 'Gast'; @override - String get userRoleUnknown => 'Unknown'; + String get userRoleUnknown => 'Unbekannt'; @override - String get inboxPageTitle => 'Inbox'; + String get inboxPageTitle => 'Eingang'; @override String get inboxEmptyPlaceholder => - 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + 'Es sind keine ungelesenen Nachrichten in deinem Eingang. Verwende die Buttons unten um den kombinierten Feed oder die Kanalliste anzusehen.'; @override - String get recentDmConversationsPageTitle => 'Direct messages'; + String get recentDmConversationsPageTitle => 'Direktnachrichten'; @override - String get recentDmConversationsSectionHeader => 'Direct messages'; + String get recentDmConversationsSectionHeader => 'Direktnachrichten'; @override String get recentDmConversationsEmptyPlaceholder => - 'You have no direct messages yet! Why not start the conversation?'; + 'Du hast noch keine Direktnachrichten! Warum nicht die Unterhaltung beginnen?'; @override - String get combinedFeedPageTitle => 'Combined feed'; + String get combinedFeedPageTitle => 'Kombinierter Feed'; @override - String get mentionsPageTitle => 'Mentions'; + String get mentionsPageTitle => 'Erwähnungen'; @override - String get starredMessagesPageTitle => 'Starred messages'; + String get starredMessagesPageTitle => 'Markierte Nachrichten'; @override - String get channelsPageTitle => 'Channels'; + String get channelsPageTitle => 'Kanäle'; @override - String get channelsEmptyPlaceholder => - 'You are not subscribed to any channels yet.'; + String get channelsEmptyPlaceholder => 'Du hast noch keine Kanäle abonniert.'; @override - String get mainMenuMyProfile => 'My profile'; + String get mainMenuMyProfile => 'Mein Profil'; @override - String get topicsButtonLabel => 'TOPICS'; + String get topicsButtonLabel => 'THEMEN'; @override - String get channelFeedButtonTooltip => 'Channel feed'; + String get channelFeedButtonTooltip => 'Kanal-Feed'; @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( numOthers, locale: localeName, - other: '$numOthers others', - one: '1 other', + other: '$numOthers weitere', + one: '1 weitere:n', ); - return '$senderFullName to you and $_temp0'; + return '$senderFullName an dich und $_temp0'; } @override - String get pinnedSubscriptionsLabel => 'Pinned'; + String get pinnedSubscriptionsLabel => 'Angeheftet'; @override - String get unpinnedSubscriptionsLabel => 'Unpinned'; + String get unpinnedSubscriptionsLabel => 'Nicht angeheftet'; @override - String get notifSelfUser => 'You'; + String get notifSelfUser => 'Du'; @override - String get reactedEmojiSelfUser => 'You'; + String get reactedEmojiSelfUser => 'Du'; @override String onePersonTyping(String typist) { - return '$typist is typing…'; + return '$typist tippt…'; } @override String twoPeopleTyping(String typist, String otherTypist) { - return '$typist and $otherTypist are typing…'; + return '$typist und $otherTypist tippen…'; } @override - String get manyPeopleTyping => 'Several people are typing…'; + String get manyPeopleTyping => 'Mehrere Leute tippen…'; @override - String get wildcardMentionAll => 'all'; + String get wildcardMentionAll => 'alle'; @override - String get wildcardMentionEveryone => 'everyone'; + String get wildcardMentionEveryone => 'jeder'; @override - String get wildcardMentionChannel => 'channel'; + String get wildcardMentionChannel => 'Kanal'; @override - String get wildcardMentionStream => 'stream'; + String get wildcardMentionStream => 'Stream'; @override - String get wildcardMentionTopic => 'topic'; + String get wildcardMentionTopic => 'Thema'; @override - String get wildcardMentionChannelDescription => 'Notify channel'; + String get wildcardMentionChannelDescription => 'Kanal benachrichtigen'; @override - String get wildcardMentionStreamDescription => 'Notify stream'; + String get wildcardMentionStreamDescription => 'Stream benachrichtigen'; @override - String get wildcardMentionAllDmDescription => 'Notify recipients'; + String get wildcardMentionAllDmDescription => 'Empfänger benachrichtigen'; @override - String get wildcardMentionTopicDescription => 'Notify topic'; + String get wildcardMentionTopicDescription => 'Thema benachrichtigen'; @override - String get messageIsEditedLabel => 'EDITED'; + String get messageIsEditedLabel => 'BEARBEITET'; @override - String get messageIsMovedLabel => 'MOVED'; + String get messageIsMovedLabel => 'VERSCHOBEN'; @override - String get messageNotSentLabel => 'MESSAGE NOT SENT'; + String get messageNotSentLabel => 'NACHRICHT NICHT GESENDET'; @override String pollVoterNames(String voterNames) { - return '($voterNames)'; + return '$voterNames'; } @override - String get themeSettingTitle => 'THEME'; + String get themeSettingTitle => 'THEMA'; @override - String get themeSettingDark => 'Dark'; + String get themeSettingDark => 'Dunkel'; @override - String get themeSettingLight => 'Light'; + String get themeSettingLight => 'Hell'; @override String get themeSettingSystem => 'System'; @override - String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + String get openLinksWithInAppBrowser => 'Links mit In-App-Browser öffnen'; @override - String get pollWidgetQuestionMissing => 'No question.'; + String get pollWidgetQuestionMissing => 'Keine Frage.'; @override - String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + String get pollWidgetOptionsMissing => + 'Diese Umfrage hat noch keine Optionen.'; @override - String get initialAnchorSettingTitle => 'Open message feeds at'; + String get initialAnchorSettingTitle => 'Nachrichten-Feed öffnen bei'; @override String get initialAnchorSettingDescription => - 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + 'Du kannst auswählen ob Nachrichten-Feeds bei deiner ersten ungelesenen oder bei den neuesten Nachrichten geöffnet werden.'; @override - String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + String get initialAnchorSettingFirstUnreadAlways => + 'Erste ungelesene Nachricht'; @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'Erste ungelesene Nachricht in Einzelunterhaltungen, sonst neueste Nachricht'; @override - String get initialAnchorSettingNewestAlways => 'Newest message'; + String get initialAnchorSettingNewestAlways => 'Neueste Nachricht'; @override - String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + String get markReadOnScrollSettingTitle => + 'Nachrichten beim Scrollen als gelesen markieren'; @override String get markReadOnScrollSettingDescription => - 'When scrolling through messages, should they automatically be marked as read?'; + 'Sollen Nachrichten automatisch als gelesen markiert werden, wenn du sie durchscrollst?'; @override - String get markReadOnScrollSettingAlways => 'Always'; + String get markReadOnScrollSettingAlways => 'Immer'; @override - String get markReadOnScrollSettingNever => 'Never'; + String get markReadOnScrollSettingNever => 'Nie'; @override String get markReadOnScrollSettingConversations => - 'Only in conversation views'; + 'Nur in Unterhaltungsansichten'; @override String get markReadOnScrollSettingConversationsDescription => - 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + 'Nachrichten werden nur beim Ansehen einzelner Themen oder Direktnachrichten automatisch als gelesen markiert.'; @override - String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + String get experimentalFeatureSettingsPageTitle => + 'Experimentelle Funktionen'; @override String get experimentalFeatureSettingsWarning => - 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + 'Diese Optionen aktivieren Funktionen, die noch in Entwicklung und nicht bereit sind. Sie funktionieren möglicherweise nicht und können Problem in anderen Bereichen der App verursachen.\n\nDer Zweck dieser Einstellungen ist das Experimentieren der Leute, die an der Entwicklung von Zulip arbeiten.'; @override - String get errorNotificationOpenTitle => 'Failed to open notification'; + String get errorNotificationOpenTitle => + 'Fehler beim Öffnen der Benachrichtigung'; @override String get errorNotificationOpenAccountNotFound => - 'The account associated with this notification could not be found.'; + 'Der Account, der mit dieser Benachrichtigung verknüpft ist, konnte nicht gefunden werden.'; @override - String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + String get errorReactionAddingFailedTitle => + 'Hinzufügen der Reaktion fehlgeschlagen'; @override - String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + String get errorReactionRemovingFailedTitle => + 'Entfernen der Reaktion fehlgeschlagen'; @override - String get emojiReactionsMore => 'more'; + String get emojiReactionsMore => 'mehr'; @override - String get emojiPickerSearchEmoji => 'Search emoji'; + String get emojiPickerSearchEmoji => 'Emoji suchen'; @override - String get noEarlierMessages => 'No earlier messages'; + String get noEarlierMessages => 'Keine früheren Nachrichten'; @override - String get mutedSender => 'Muted sender'; + String get mutedSender => 'Stummgeschalteter Absender'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => + 'Nachricht für stummgeschalteten Absender anzeigen'; @override - String get mutedUser => 'Muted user'; + String get mutedUser => 'Stummgeschaltete:r Nutzer:in'; @override - String get scrollToBottomTooltip => 'Scroll to bottom'; + String get scrollToBottomTooltip => 'Nach unten Scrollen'; @override String get appVersionUnknownPlaceholder => '(…)'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 3382e76c02..32866e7d59 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -138,13 +138,13 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get actionSheetOptionShare => 'Condividi'; @override - String get actionSheetOptionQuoteMessage => 'Quote message'; + String get actionSheetOptionQuoteMessage => 'Cita messaggio'; @override - String get actionSheetOptionStarMessage => 'Metti una stella al messaggio'; + String get actionSheetOptionStarMessage => 'Messaggio speciale'; @override - String get actionSheetOptionUnstarMessage => 'Togli la stella dal messaggio'; + String get actionSheetOptionUnstarMessage => 'Messaggio normale'; @override String get actionSheetOptionEditMessage => 'Modifica messaggio'; @@ -194,10 +194,10 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num files are', - one: 'File is', + other: '$num file sono', + one: 'file è', ); - return '$_temp0 larger than the server\'s limit of $maxFileUploadSizeMib MiB and will not be uploaded:\n\n$listMessage'; + return '$_temp0 più grande/i del limite del server di $maxFileUploadSizeMib MiB e non verrà/anno caricato/i:\n\n$listMessage'; } @override @@ -205,10 +205,10 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: 'Files', + other: 'File', one: 'File', ); - return '$_temp0 too large'; + return '$_temp0 troppo grande/i'; } @override @@ -262,280 +262,285 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String error, String event, ) { - return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; + return 'Errore nella gestione di un evento Zulip da $serverUrl; verrà effettuato un nuovo tentativo.\n\nErrore: $error\n\nEvento: $event'; } @override - String get errorCouldNotOpenLinkTitle => 'Unable to open link'; + String get errorCouldNotOpenLinkTitle => 'Impossibile aprire il collegamento'; @override String errorCouldNotOpenLink(String url) { - return 'Link could not be opened: $url'; + return 'Impossibile aprire il collegamento: $url'; } @override - String get errorMuteTopicFailed => 'Failed to mute topic'; + String get errorMuteTopicFailed => 'Impossibile silenziare l\'argomento'; @override - String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + String get errorUnmuteTopicFailed => 'Impossibile de-silenziare l\'argomento'; @override - String get errorFollowTopicFailed => 'Failed to follow topic'; + String get errorFollowTopicFailed => 'Impossibile seguire l\'argomento'; @override - String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + String get errorUnfollowTopicFailed => + 'Impossibile smettere di seguire l\'argomento'; @override - String get errorSharingFailed => 'Sharing failed'; + String get errorSharingFailed => 'Condivisione fallita'; @override - String get errorStarMessageFailedTitle => 'Failed to star message'; + String get errorStarMessageFailedTitle => + 'Impossibile contrassegnare il messaggio come speciale'; @override - String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + String get errorUnstarMessageFailedTitle => + 'Impossibile contrassegnare il messaggio come normale'; @override - String get errorCouldNotEditMessageTitle => 'Could not edit message'; + String get errorCouldNotEditMessageTitle => + 'Impossibile modificare il messaggio'; @override - String get successLinkCopied => 'Link copied'; + String get successLinkCopied => 'Collegamento copiato'; @override - String get successMessageTextCopied => 'Message text copied'; + String get successMessageTextCopied => 'Testo messaggio copiato'; @override - String get successMessageLinkCopied => 'Message link copied'; + String get successMessageLinkCopied => 'Collegamento messaggio copiato'; @override String get errorBannerDeactivatedDmLabel => - 'You cannot send messages to deactivated users.'; + 'Non è possibile inviare messaggi agli utenti disattivati.'; @override String get errorBannerCannotPostInChannelLabel => - 'You do not have permission to post in this channel.'; + 'Non hai l\'autorizzazione per postare su questo canale.'; @override - String get composeBoxBannerLabelEditMessage => 'Edit message'; + String get composeBoxBannerLabelEditMessage => 'Modifica messaggio'; @override - String get composeBoxBannerButtonCancel => 'Cancel'; + String get composeBoxBannerButtonCancel => 'Annulla'; @override - String get composeBoxBannerButtonSave => 'Save'; + String get composeBoxBannerButtonSave => 'Salva'; @override - String get editAlreadyInProgressTitle => 'Cannot edit message'; + String get editAlreadyInProgressTitle => + 'Impossibile modificare il messaggio'; @override String get editAlreadyInProgressMessage => - 'An edit is already in progress. Please wait for it to complete.'; + 'Una modifica è già in corso. Attendere il completamento.'; @override - String get savingMessageEditLabel => 'SAVING EDIT…'; + String get savingMessageEditLabel => 'SALVATAGGIO MODIFICA…'; @override - String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + String get savingMessageEditFailedLabel => 'MODIFICA NON SALVATA'; @override String get discardDraftConfirmationDialogTitle => - 'Discard the message you’re writing?'; + 'Scartare il messaggio che si sta scrivendo?'; @override String get discardDraftForEditConfirmationDialogMessage => - 'When you edit a message, the content that was previously in the compose box is discarded.'; + 'Quando si modifica un messaggio, il contenuto precedentemente presente nella casella di composizione viene ignorato.'; @override String get discardDraftForOutboxConfirmationDialogMessage => - 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + 'Quando si recupera un messaggio non inviato, il contenuto precedentemente presente nella casella di composizione viene ignorato.'; @override - String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + String get discardDraftConfirmationDialogConfirmButton => 'Abbandona'; @override - String get composeBoxAttachFilesTooltip => 'Attach files'; + String get composeBoxAttachFilesTooltip => 'Allega file'; @override - String get composeBoxAttachMediaTooltip => 'Attach images or videos'; + String get composeBoxAttachMediaTooltip => 'Allega immagini o video'; @override - String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + String get composeBoxAttachFromCameraTooltip => 'Fai una foto'; @override - String get composeBoxGenericContentHint => 'Type a message'; + String get composeBoxGenericContentHint => 'Batti un messaggio'; @override - String get newDmSheetComposeButtonLabel => 'Compose'; + String get newDmSheetComposeButtonLabel => 'Componi'; @override - String get newDmSheetScreenTitle => 'New DM'; + String get newDmSheetScreenTitle => 'Nuovo MD'; @override - String get newDmFabButtonLabel => 'New DM'; + String get newDmFabButtonLabel => 'Nuovo MD'; @override - String get newDmSheetSearchHintEmpty => 'Add one or more users'; + String get newDmSheetSearchHintEmpty => 'Aggiungi uno o più utenti'; @override - String get newDmSheetSearchHintSomeSelected => 'Add another user…'; + String get newDmSheetSearchHintSomeSelected => 'Aggiungi un altro utente…'; @override - String get newDmSheetNoUsersFound => 'No users found'; + String get newDmSheetNoUsersFound => 'Nessun utente trovato'; @override String composeBoxDmContentHint(String user) { - return 'Message @$user'; + return 'Messaggia @$user'; } @override - String get composeBoxGroupDmContentHint => 'Message group'; + String get composeBoxGroupDmContentHint => 'Gruppo di messaggi'; @override - String get composeBoxSelfDmContentHint => 'Jot down something'; + String get composeBoxSelfDmContentHint => 'Annota qualcosa'; @override String composeBoxChannelContentHint(String destination) { - return 'Message $destination'; + return 'Messaggia $destination'; } @override - String get preparingEditMessageContentInput => 'Preparing…'; + String get preparingEditMessageContentInput => 'Preparazione…'; @override - String get composeBoxSendTooltip => 'Send'; + String get composeBoxSendTooltip => 'Invia'; @override - String get unknownChannelName => '(unknown channel)'; + String get unknownChannelName => '(canale sconosciuto)'; @override - String get composeBoxTopicHintText => 'Topic'; + String get composeBoxTopicHintText => 'Argomento'; @override String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { - return 'Enter a topic (skip for “$defaultTopicName”)'; + return 'Inserisci un argomento (salta per \"$defaultTopicName\")'; } @override String composeBoxUploadingFilename(String filename) { - return 'Uploading $filename…'; + return 'Caricamento $filename…'; } @override String composeBoxLoadingMessage(int messageId) { - return '(loading message $messageId)'; + return '(caricamento messaggio $messageId)'; } @override - String get unknownUserName => '(unknown user)'; + String get unknownUserName => '(utente sconosciuto)'; @override - String get dmsWithYourselfPageTitle => 'DMs with yourself'; + String get dmsWithYourselfPageTitle => 'MD con te stesso'; @override String messageListGroupYouAndOthers(String others) { - return 'You and $others'; + return 'Tu e $others'; } @override String dmsWithOthersPageTitle(String others) { - return 'DMs with $others'; + return 'MD con $others'; } @override - String get messageListGroupYouWithYourself => 'Messages with yourself'; + String get messageListGroupYouWithYourself => 'Messaggi con te stesso'; @override String get contentValidationErrorTooLong => - 'Message length shouldn\'t be greater than 10000 characters.'; + 'La lunghezza del messaggio non deve essere superiore a 10.000 caratteri.'; @override - String get contentValidationErrorEmpty => 'You have nothing to send!'; + String get contentValidationErrorEmpty => 'Non devi inviare nulla!'; @override String get contentValidationErrorQuoteAndReplyInProgress => - 'Please wait for the quotation to complete.'; + 'Attendere il completamento del commento.'; @override String get contentValidationErrorUploadInProgress => - 'Please wait for the upload to complete.'; + 'Attendere il completamento del caricamento.'; @override - String get dialogCancel => 'Cancel'; + String get dialogCancel => 'Annulla'; @override - String get dialogContinue => 'Continue'; + String get dialogContinue => 'Continua'; @override - String get dialogClose => 'Close'; + String get dialogClose => 'Chiudi'; @override - String get errorDialogLearnMore => 'Learn more'; + String get errorDialogLearnMore => 'Scopri di più'; @override - String get errorDialogContinue => 'OK'; + String get errorDialogContinue => 'Ok'; @override - String get errorDialogTitle => 'Error'; + String get errorDialogTitle => 'Errore'; @override - String get snackBarDetails => 'Details'; + String get snackBarDetails => 'Dettagli'; @override - String get lightboxCopyLinkTooltip => 'Copy link'; + String get lightboxCopyLinkTooltip => 'Copia collegamento'; @override - String get lightboxVideoCurrentPosition => 'Current position'; + String get lightboxVideoCurrentPosition => 'Posizione corrente'; @override - String get lightboxVideoDuration => 'Video duration'; + String get lightboxVideoDuration => 'Durata video'; @override - String get loginPageTitle => 'Log in'; + String get loginPageTitle => 'Accesso'; @override - String get loginFormSubmitLabel => 'Log in'; + String get loginFormSubmitLabel => 'Accesso'; @override - String get loginMethodDivider => 'OR'; + String get loginMethodDivider => 'O'; @override String signInWithFoo(String method) { - return 'Sign in with $method'; + return 'Accedi con $method'; } @override - String get loginAddAnAccountPageTitle => 'Add an account'; + String get loginAddAnAccountPageTitle => 'Aggiungi account'; @override - String get loginServerUrlLabel => 'Your Zulip server URL'; + String get loginServerUrlLabel => 'URL del server Zulip'; @override - String get loginHidePassword => 'Hide password'; + String get loginHidePassword => 'Nascondi password'; @override - String get loginEmailLabel => 'Email address'; + String get loginEmailLabel => 'Indirizzo email'; @override - String get loginErrorMissingEmail => 'Please enter your email.'; + String get loginErrorMissingEmail => 'Inserire l\'email.'; @override String get loginPasswordLabel => 'Password'; @override - String get loginErrorMissingPassword => 'Please enter your password.'; + String get loginErrorMissingPassword => 'Inserire la propria password.'; @override - String get loginUsernameLabel => 'Username'; + String get loginUsernameLabel => 'Nomeutente'; @override - String get loginErrorMissingUsername => 'Please enter your username.'; + String get loginErrorMissingUsername => 'Inserire il proprio nomeutente.'; @override String get topicValidationErrorTooLong => - 'Topic length shouldn\'t be greater than 60 characters.'; + 'La lunghezza dell\'argomento non deve superare i 60 caratteri.'; @override String get topicValidationErrorMandatoryButEmpty => - 'Topics are required in this organization.'; + 'In questa organizzazione sono richiesti degli argomenti.'; @override String errorServerVersionUnsupportedMessage( @@ -543,229 +548,233 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String zulipVersion, String minSupportedZulipVersion, ) { - return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + return '$url sta usando Zulip Server $zulipVersion, che non è supportato. La versione minima supportata è Zulip Server $minSupportedZulipVersion.'; } @override String errorInvalidApiKeyMessage(String url) { - return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; + return 'L\'account su $url non è stato autenticato. Riprovare ad accedere o provare a usare un altro account.'; } @override - String get errorInvalidResponse => 'The server sent an invalid response.'; + String get errorInvalidResponse => + 'Il server ha inviato una risposta non valida.'; @override - String get errorNetworkRequestFailed => 'Network request failed'; + String get errorNetworkRequestFailed => 'Richiesta di rete non riuscita'; @override String errorMalformedResponse(int httpStatus) { - return 'Server gave malformed response; HTTP status $httpStatus'; + return 'Il server ha fornito una risposta non valida; stato HTTP $httpStatus'; } @override String errorMalformedResponseWithCause(int httpStatus, String details) { - return 'Server gave malformed response; HTTP status $httpStatus; $details'; + return 'Il server ha fornito una risposta non valida; stato HTTP $httpStatus; $details'; } @override String errorRequestFailed(int httpStatus) { - return 'Network request failed: HTTP status $httpStatus'; + return 'Richiesta di rete non riuscita: stato HTTP $httpStatus'; } @override - String get errorVideoPlayerFailed => 'Unable to play the video.'; + String get errorVideoPlayerFailed => 'Impossibile riprodurre il video.'; @override - String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; + String get serverUrlValidationErrorEmpty => 'Inserire un URL.'; @override - String get serverUrlValidationErrorInvalidUrl => 'Please enter a valid URL.'; + String get serverUrlValidationErrorInvalidUrl => 'Inserire un URL valido.'; @override String get serverUrlValidationErrorNoUseEmail => - 'Please enter the server URL, not your email.'; + 'Inserire l\'URL del server, non il proprio indirizzo email.'; @override String get serverUrlValidationErrorUnsupportedScheme => - 'The server URL must start with http:// or https://.'; + 'L\'URL del server deve iniziare con http:// o https://.'; @override String get spoilerDefaultHeaderText => 'Spoiler'; @override - String get markAllAsReadLabel => 'Mark all messages as read'; + String get markAllAsReadLabel => 'Segna tutti i messaggi come letti'; @override String markAsReadComplete(int num) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num messages', - one: '1 message', + other: '$num messagei', + one: '1 messaggio', ); - return 'Marked $_temp0 as read.'; + return 'Segnato/i $_temp0 come letto/i.'; } @override - String get markAsReadInProgress => 'Marking messages as read…'; + String get markAsReadInProgress => 'Contrassegno dei messaggi come letti…'; @override - String get errorMarkAsReadFailedTitle => 'Mark as read failed'; + String get errorMarkAsReadFailedTitle => + 'Contrassegno come letto non riuscito'; @override String markAsUnreadComplete(int num) { String _temp0 = intl.Intl.pluralLogic( num, locale: localeName, - other: '$num messages', - one: '1 message', + other: '$num messagi', + one: '1 messaggio', ); - return 'Marked $_temp0 as unread.'; + return 'Segnato/i $_temp0 come non letto/i.'; } @override - String get markAsUnreadInProgress => 'Marking messages as unread…'; + String get markAsUnreadInProgress => + 'Contrassegno dei messaggi come non letti…'; @override - String get errorMarkAsUnreadFailedTitle => 'Mark as unread failed'; + String get errorMarkAsUnreadFailedTitle => + 'Contrassegno come non letti non riuscito'; @override - String get today => 'Today'; + String get today => 'Oggi'; @override - String get yesterday => 'Yesterday'; + String get yesterday => 'Ieri'; @override - String get userRoleOwner => 'Owner'; + String get userRoleOwner => 'Proprietario'; @override - String get userRoleAdministrator => 'Administrator'; + String get userRoleAdministrator => 'Amministratore'; @override - String get userRoleModerator => 'Moderator'; + String get userRoleModerator => 'Moderatore'; @override - String get userRoleMember => 'Member'; + String get userRoleMember => 'Membro'; @override - String get userRoleGuest => 'Guest'; + String get userRoleGuest => 'Ospite'; @override - String get userRoleUnknown => 'Unknown'; + String get userRoleUnknown => 'Sconosciuto'; @override String get inboxPageTitle => 'Inbox'; @override String get inboxEmptyPlaceholder => - 'There are no unread messages in your inbox. Use the buttons below to view the combined feed or list of channels.'; + 'Non ci sono messaggi non letti nella posta in arrivo. Usare i pulsanti sotto per visualizzare il feed combinato o l\'elenco dei canali.'; @override - String get recentDmConversationsPageTitle => 'Direct messages'; + String get recentDmConversationsPageTitle => 'Messaggi diretti'; @override - String get recentDmConversationsSectionHeader => 'Direct messages'; + String get recentDmConversationsSectionHeader => 'Messaggi diretti'; @override String get recentDmConversationsEmptyPlaceholder => - 'You have no direct messages yet! Why not start the conversation?'; + 'Non ci sono ancora messaggi diretti! Perché non iniziare la conversazione?'; @override - String get combinedFeedPageTitle => 'Combined feed'; + String get combinedFeedPageTitle => 'Feed combinato'; @override - String get mentionsPageTitle => 'Mentions'; + String get mentionsPageTitle => 'Menzioni'; @override - String get starredMessagesPageTitle => 'Starred messages'; + String get starredMessagesPageTitle => 'Messaggi speciali'; @override - String get channelsPageTitle => 'Channels'; + String get channelsPageTitle => 'Canali'; @override String get channelsEmptyPlaceholder => - 'You are not subscribed to any channels yet.'; + 'Non sei ancora iscritto ad alcun canale.'; @override - String get mainMenuMyProfile => 'My profile'; + String get mainMenuMyProfile => 'Il mio profilo'; @override - String get topicsButtonLabel => 'TOPICS'; + String get topicsButtonLabel => 'ARGOMENTI'; @override - String get channelFeedButtonTooltip => 'Channel feed'; + String get channelFeedButtonTooltip => 'Feed del canale'; @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( numOthers, locale: localeName, - other: '$numOthers others', - one: '1 other', + other: '$numOthers altri', + one: '1 altro', ); - return '$senderFullName to you and $_temp0'; + return '$senderFullName a te e $_temp0'; } @override - String get pinnedSubscriptionsLabel => 'Pinned'; + String get pinnedSubscriptionsLabel => 'Bloccato'; @override - String get unpinnedSubscriptionsLabel => 'Unpinned'; + String get unpinnedSubscriptionsLabel => 'Non bloccato'; @override - String get notifSelfUser => 'You'; + String get notifSelfUser => 'Tu'; @override - String get reactedEmojiSelfUser => 'You'; + String get reactedEmojiSelfUser => 'Tu'; @override String onePersonTyping(String typist) { - return '$typist is typing…'; + return '$typist sta scrivendo…'; } @override String twoPeopleTyping(String typist, String otherTypist) { - return '$typist and $otherTypist are typing…'; + return '$typist e $otherTypist stanno scrivendo…'; } @override - String get manyPeopleTyping => 'Several people are typing…'; + String get manyPeopleTyping => 'Molte persone stanno scrivendo…'; @override - String get wildcardMentionAll => 'all'; + String get wildcardMentionAll => 'tutti'; @override - String get wildcardMentionEveryone => 'everyone'; + String get wildcardMentionEveryone => 'ognuno'; @override - String get wildcardMentionChannel => 'channel'; + String get wildcardMentionChannel => 'canale'; @override - String get wildcardMentionStream => 'stream'; + String get wildcardMentionStream => 'flusso'; @override - String get wildcardMentionTopic => 'topic'; + String get wildcardMentionTopic => 'argomento'; @override - String get wildcardMentionChannelDescription => 'Notify channel'; + String get wildcardMentionChannelDescription => 'Notifica canale'; @override - String get wildcardMentionStreamDescription => 'Notify stream'; + String get wildcardMentionStreamDescription => 'Notifica flusso'; @override - String get wildcardMentionAllDmDescription => 'Notify recipients'; + String get wildcardMentionAllDmDescription => 'Notifica destinatari'; @override - String get wildcardMentionTopicDescription => 'Notify topic'; + String get wildcardMentionTopicDescription => 'Notifica argomento'; @override - String get messageIsEditedLabel => 'EDITED'; + String get messageIsEditedLabel => 'MODIFICATO'; @override - String get messageIsMovedLabel => 'MOVED'; + String get messageIsMovedLabel => 'SPOSTATO'; @override - String get messageNotSentLabel => 'MESSAGE NOT SENT'; + String get messageNotSentLabel => 'MESSAGGIO NON INVIATO'; @override String pollVoterNames(String voterNames) { @@ -773,104 +782,111 @@ class ZulipLocalizationsIt extends ZulipLocalizations { } @override - String get themeSettingTitle => 'THEME'; + String get themeSettingTitle => 'TEMA'; @override - String get themeSettingDark => 'Dark'; + String get themeSettingDark => 'Scuro'; @override - String get themeSettingLight => 'Light'; + String get themeSettingLight => 'Chiaro'; @override - String get themeSettingSystem => 'System'; + String get themeSettingSystem => 'Sistema'; @override - String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + String get openLinksWithInAppBrowser => + 'Apri i collegamenti con il browser in-app'; @override - String get pollWidgetQuestionMissing => 'No question.'; + String get pollWidgetQuestionMissing => 'Nessuna domanda.'; @override - String get pollWidgetOptionsMissing => 'This poll has no options yet.'; + String get pollWidgetOptionsMissing => + 'Questo sondaggio non ha ancora opzioni.'; @override - String get initialAnchorSettingTitle => 'Open message feeds at'; + String get initialAnchorSettingTitle => 'Apri i feed dei messaggi su'; @override String get initialAnchorSettingDescription => - 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + 'È possibile scegliere se i feed dei messaggi devono aprirsi al primo messaggio non letto oppure ai messaggi più recenti.'; @override - String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + String get initialAnchorSettingFirstUnreadAlways => + 'Primo messaggio non letto'; @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'Primo messaggio non letto nelle singole conversazioni, messaggio più recente altrove'; @override - String get initialAnchorSettingNewestAlways => 'Newest message'; + String get initialAnchorSettingNewestAlways => 'Messaggio più recente'; @override - String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + String get markReadOnScrollSettingTitle => + 'Segna i messaggi come letti durante lo scorrimento'; @override String get markReadOnScrollSettingDescription => - 'When scrolling through messages, should they automatically be marked as read?'; + 'Quando si scorrono i messaggi, questi devono essere contrassegnati automaticamente come letti?'; @override - String get markReadOnScrollSettingAlways => 'Always'; + String get markReadOnScrollSettingAlways => 'Sempre'; @override - String get markReadOnScrollSettingNever => 'Never'; + String get markReadOnScrollSettingNever => 'Mai'; @override String get markReadOnScrollSettingConversations => - 'Only in conversation views'; + 'Solo nelle visualizzazioni delle conversazioni'; @override String get markReadOnScrollSettingConversationsDescription => - 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + 'I messaggi verranno automaticamente contrassegnati come in sola lettura quando si visualizza un singolo argomento o una conversazione in un messaggio diretto.'; @override - String get experimentalFeatureSettingsPageTitle => 'Experimental features'; + String get experimentalFeatureSettingsPageTitle => + 'Caratteristiche sperimentali'; @override String get experimentalFeatureSettingsWarning => - 'These options enable features which are still under development and not ready. They may not work, and may cause issues in other areas of the app.\n\nThe purpose of these settings is for experimentation by people working on developing Zulip.'; + 'Queste opzioni abilitano funzionalità ancora in fase di sviluppo e non ancora pronte. Potrebbero non funzionare e causare problemi in altre aree dell\'app.\n\nQueste impostazioni sono pensate per la sperimentazione da parte di chi lavora allo sviluppo di Zulip.'; @override - String get errorNotificationOpenTitle => 'Failed to open notification'; + String get errorNotificationOpenTitle => 'Impossibile aprire la notifica'; @override String get errorNotificationOpenAccountNotFound => - 'The account associated with this notification could not be found.'; + 'Impossibile trovare l\'account associato a questa notifica.'; @override - String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + String get errorReactionAddingFailedTitle => + 'Aggiunta della reazione non riuscita'; @override - String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + String get errorReactionRemovingFailedTitle => + 'Rimozione della reazione non riuscita'; @override - String get emojiReactionsMore => 'more'; + String get emojiReactionsMore => 'altro'; @override - String get emojiPickerSearchEmoji => 'Search emoji'; + String get emojiPickerSearchEmoji => 'Cerca emoji'; @override - String get noEarlierMessages => 'No earlier messages'; + String get noEarlierMessages => 'Nessun messaggio precedente'; @override - String get mutedSender => 'Muted sender'; + String get mutedSender => 'Mittente silenziato'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Mostra messaggio per mittente silenziato'; @override - String get mutedUser => 'Muted user'; + String get mutedUser => 'Utente silenziato'; @override - String get scrollToBottomTooltip => 'Scroll to bottom'; + String get scrollToBottomTooltip => 'Scorri fino in fondo'; @override String get appVersionUnknownPlaceholder => '(…)'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index cf867ed9b6..41a4efce29 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -140,7 +140,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionShare => 'Udostępnij'; @override - String get actionSheetOptionQuoteMessage => 'Quote message'; + String get actionSheetOptionQuoteMessage => 'Cytuj wiadomość'; @override String get actionSheetOptionStarMessage => 'Oznacz gwiazdką'; @@ -816,25 +816,25 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get initialAnchorSettingNewestAlways => 'Najnowsza wiadomość'; @override - String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + String get markReadOnScrollSettingTitle => + 'Oznacz wiadomości jako przeczytane przy przwijaniu'; @override String get markReadOnScrollSettingDescription => - 'When scrolling through messages, should they automatically be marked as read?'; + 'Czy chcesz z automatu oznaczać wiadomości jako przeczytane przy przewijaniu?'; @override - String get markReadOnScrollSettingAlways => 'Always'; + String get markReadOnScrollSettingAlways => 'Zawsze'; @override - String get markReadOnScrollSettingNever => 'Never'; + String get markReadOnScrollSettingNever => 'Nigdy'; @override - String get markReadOnScrollSettingConversations => - 'Only in conversation views'; + String get markReadOnScrollSettingConversations => 'Tylko w widoku dyskusji'; @override String get markReadOnScrollSettingConversationsDescription => - 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + 'Wiadomości zostaną z automatu oznaczone jako przeczytane tylko w pojedyczym wątku lub w wymianie wiadomości bezpośrednich.'; @override String get experimentalFeatureSettingsPageTitle => 'Funkcje eksperymentalne'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 09a97476f6..ba78c0c8ec 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -140,7 +140,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionShare => 'Поделиться'; @override - String get actionSheetOptionQuoteMessage => 'Quote message'; + String get actionSheetOptionQuoteMessage => 'Цитировать сообщение'; @override String get actionSheetOptionStarMessage => 'Отметить сообщение'; @@ -820,25 +820,26 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get initialAnchorSettingNewestAlways => 'Самое новое сообщение'; @override - String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + String get markReadOnScrollSettingTitle => + 'Отмечать сообщения как прочитанные при прокрутке'; @override String get markReadOnScrollSettingDescription => - 'When scrolling through messages, should they automatically be marked as read?'; + 'При прокрутке сообщений автоматически отмечать их как прочитанные?'; @override - String get markReadOnScrollSettingAlways => 'Always'; + String get markReadOnScrollSettingAlways => 'Всегда'; @override - String get markReadOnScrollSettingNever => 'Never'; + String get markReadOnScrollSettingNever => 'Никогда'; @override String get markReadOnScrollSettingConversations => - 'Only in conversation views'; + 'Только при просмотре бесед'; @override String get markReadOnScrollSettingConversationsDescription => - 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + 'Сообщения будут автоматически помечаться как прочитанные только при просмотре отдельной темы или личной беседы.'; @override String get experimentalFeatureSettingsPageTitle => diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index b0d195420b..bb02ed6cc2 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -878,7 +878,7 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { ZulipLocalizationsZhHansCn() : super('zh_Hans_CN'); @override - String get aboutPageTitle => '关于Zulip'; + String get aboutPageTitle => '关于 Zulip'; @override String get aboutPageAppVersion => '应用程序版本'; @@ -985,6 +985,9 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { @override String get actionSheetOptionShare => '分享'; + @override + String get actionSheetOptionQuoteMessage => '引用消息'; + @override String get actionSheetOptionStarMessage => '添加星标消息标记'; @@ -1211,11 +1214,11 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { @override String composeBoxDmContentHint(String user) { - return '私信 @$user'; + return '发送私信给 @$user'; } @override - String get composeBoxGroupDmContentHint => '私信群组'; + String get composeBoxGroupDmContentHint => '发送私信到群组'; @override String get composeBoxSelfDmContentHint => '向自己撰写消息'; @@ -1477,7 +1480,7 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { String get inboxPageTitle => '收件箱'; @override - String get inboxEmptyPlaceholder => '你的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。'; + String get inboxEmptyPlaceholder => '您的收件箱中没有未读消息。您可以通过底部导航栏访问综合消息或者频道列表。'; @override String get recentDmConversationsPageTitle => '私信'; @@ -1492,7 +1495,7 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { String get combinedFeedPageTitle => '综合消息'; @override - String get mentionsPageTitle => '@提及'; + String get mentionsPageTitle => '被提及消息'; @override String get starredMessagesPageTitle => '星标消息'; @@ -1519,7 +1522,7 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { locale: localeName, other: '$numOthers 个用户', ); - return '$senderFullName向你和其他 $_temp0'; + return '$senderFullName向您和其他 $_temp0'; } @override @@ -1592,7 +1595,7 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { String get themeSettingTitle => '主题'; @override - String get themeSettingDark => '深色'; + String get themeSettingDark => '暗色'; @override String get themeSettingLight => '浅色'; @@ -1625,12 +1628,31 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { @override String get initialAnchorSettingNewestAlways => '最新消息'; + @override + String get markReadOnScrollSettingTitle => '滑动时将消息标为已读'; + + @override + String get markReadOnScrollSettingDescription => '在滑动浏览消息时,是否自动将它们标记为已读?'; + + @override + String get markReadOnScrollSettingAlways => '总是'; + + @override + String get markReadOnScrollSettingNever => '从不'; + + @override + String get markReadOnScrollSettingConversations => '只在对话视图'; + + @override + String get markReadOnScrollSettingConversationsDescription => + '只将在同一个话题或私聊中的消息自动标记为已读。'; + @override String get experimentalFeatureSettingsPageTitle => '实验功能'; @override String get experimentalFeatureSettingsWarning => - '以下选项启用了一些正在开发中的功能。它们可能不能正常使用,或造成一些其他的问题。\n\n这些选项能够帮助开发者更好的试验这些功能。'; + '以下选项能够启用开发中的功能。它们暂不完善,并可能造成其他的一些问题。\n\n这些选项的目的是为了帮助开发者进行实验。'; @override String get errorNotificationOpenTitle => '未能打开消息提醒'; From a6ea882099ebbb0ac9da37ac68c25a2dab95efd7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 15 Jun 2025 13:59:26 -0700 Subject: [PATCH 208/290] version: Sync version and changelog from v30.0.256 release --- docs/changelog.md | 42 ++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 2f514a89f9..0c0177350a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,48 @@ ## Unreleased +## 30.0.256 (2025-06-15) + +With this release, this new app takes on the identity +of the main Zulip app! + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for users (vs last beta, v0.0.33) + +* This app now uses the app ID of the main Zulip mobile app, + formerly used by the legacy app. It therefore installs over + any previous install of the legacy app, rather than of the + Flutter beta app. (#1582) +* The app's icon and name no longer say "beta". (#1537) +* Migrate accounts and settings from the legacy app's data. (#1070) +* Show welcome dialog on upgrading from legacy app. (#1580) + + +### Highlights for developers + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + * #1537 via PR #1577 + * #1582 via PR #1586 + * #1070 via PR #1588 + * #1580 via PR #1590 + + ## 0.0.33 (2025-06-13) This is a preview beta, including some experimental changes diff --git a/pubspec.yaml b/pubspec.yaml index ef3c5bc5ec..235487740a 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.33+33 +version: 30.0.256+256 environment: # We use a recent version of Flutter from its main channel, and From 725bb5edad0a35428a4e1f253c920d9004e15ffb Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 15 Jun 2025 22:16:14 -0700 Subject: [PATCH 209/290] version: Sync version and changelog from v30.0.257 release --- docs/changelog.md | 41 +++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 0c0177350a..a61ad4ca09 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,11 +3,52 @@ ## Unreleased +## 30.0.257 (2025-06-15) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement: +https://groups.google.com/g/zulip-announce/c/PfcyFY4cIMA + + +### Highlights for users (vs previous alpha, v30.0.256) + +* Translation updates, including near-complete translations + for German (de) and Italian (it). + + +### Highlights for developers + +* User-visible changes not described above: + * Updated link in welcome dialog. (part of #1580) + * Skip ackedPushToken in migrated account data. + (part of #1070) + +* Resolved in main: #1537, #1582 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + * #1070 via PR #1588 + * #1580 via PR #1590 + + ## 30.0.256 (2025-06-15) With this release, this new app takes on the identity of the main Zulip app! +This was an alpha-only release. + This release branch includes some experimental changes not yet merged to the main branch. diff --git a/pubspec.yaml b/pubspec.yaml index 235487740a..351ef504ea 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: 30.0.256+256 +version: 30.0.257+257 environment: # We use a recent version of Flutter from its main channel, and From 3ae72ab76544078284cfd849c528fd0c2b85226a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 15 Jun 2025 15:44:29 -0700 Subject: [PATCH 210/290] changelog: Update link in v30.0.257 user notes This updated version is what I actually included in the various announcements of the release. --- docs/changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index a61ad4ca09..256bcea822 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,8 +15,8 @@ Welcome to the new Zulip mobile app! You'll find a familiar experience in a faster, sleeker package. For more information or to send us feedback, -see the announcement: -https://groups.google.com/g/zulip-announce/c/PfcyFY4cIMA +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch ### Highlights for users (vs previous alpha, v30.0.256) From 76e64be0c7cb596c0ae43aff8b27370a050703c9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 14 Jun 2025 16:50:36 -0700 Subject: [PATCH 211/290] db: Debug-log a bit more on schema migrations --- lib/model/database.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/model/database.dart b/lib/model/database.dart index e20380b9e7..33b877dce9 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -191,6 +191,7 @@ class AppDatabase extends _$AppDatabase { ); Future _createLatestSchema(Migrator m) async { + assert(debugLog('Creating DB schema from scratch.')); await m.createAll(); // Corresponds to `from4to5` above. await into(globalSettings).insert(GlobalSettingsCompanion()); @@ -205,7 +206,7 @@ class AppDatabase extends _$AppDatabase { // This should only ever happen in dev. As a dev convenience, // drop everything from the database and start over. // TODO(log): log schema downgrade as an error - assert(debugLog('Downgrading schema from v$from to v$to.')); + assert(debugLog('Downgrading DB schema from v$from to v$to.')); // In the actual app, the target schema version is always // the latest version as of the code that's being run. @@ -219,6 +220,7 @@ class AppDatabase extends _$AppDatabase { } assert(1 <= from && from <= to && to <= latestSchemaVersion); + assert(debugLog('Upgrading DB schema from v$from to v$to.')); await m.runMigrationSteps(from: from, to: to, steps: _migrationSteps); }); } From 9bcb2a574eec1a401bf108ecd4b24c7b5a302cd5 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 12 Jun 2025 21:17:46 -0700 Subject: [PATCH 212/290] legacy-data: Describe LegacyAppData type and deserializers --- lib/model/legacy_app_data.dart | 220 +++++++++++++++++++++++++++++++ lib/model/legacy_app_data.g.dart | 116 ++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 lib/model/legacy_app_data.dart create mode 100644 lib/model/legacy_app_data.g.dart diff --git a/lib/model/legacy_app_data.dart b/lib/model/legacy_app_data.dart new file mode 100644 index 0000000000..98886ed4f4 --- /dev/null +++ b/lib/model/legacy_app_data.dart @@ -0,0 +1,220 @@ +/// Logic for reading from the legacy app's data, on upgrade to this app. +/// +/// Many of the details here correspond to specific parts of the +/// legacy app's source code. +/// See . +// TODO(#1593): write tests for this file +library; + +import 'package:json_annotation/json_annotation.dart'; + +part 'legacy_app_data.g.dart'; + +/// Represents the data from the legacy app's database, +/// so far as it's relevant for this app. +/// +/// The full set of data in the legacy app's in-memory store is described by +/// the type `GlobalState` in src/reduxTypes.js . +/// Within that, the data it stores in the database is the data at the keys +/// listed in `storeKeys` and `cacheKeys` in src/boot/store.js . +/// The data under `cacheKeys` lives on the server and the app re-fetches it +/// upon each startup anyway; +/// so only the data under `storeKeys` is relevant for migrating to this app. +/// +/// Within the data under `storeKeys`, some portions are also ignored +/// for specific reasons described explicitly in comments on these types. +@JsonSerializable() +class LegacyAppData { + // The `state.migrations` data gets read and used before attempting to + // deserialize the data that goes into this class. + // final LegacyAppMigrationsState migrations; // handled separately + + final LegacyAppGlobalSettingsState? settings; + final List? accounts; + + // final Map drafts; // ignore; inherently transient + + // final List outbox; // ignore; inherently transient + + LegacyAppData({ + required this.settings, + required this.accounts, + }); + + factory LegacyAppData.fromJson(Map json) => + _$LegacyAppDataFromJson(json); + + Map toJson() => _$LegacyAppDataToJson(this); +} + +/// Corresponds to type `MigrationsState` in src/reduxTypes.js . +@JsonSerializable() +class LegacyAppMigrationsState { + final int? version; + + LegacyAppMigrationsState({required this.version}); + + factory LegacyAppMigrationsState.fromJson(Map json) => + _$LegacyAppMigrationsStateFromJson(json); + + Map toJson() => _$LegacyAppMigrationsStateToJson(this); +} + +/// Corresponds to type `GlobalSettingsState` in src/reduxTypes.js . +/// +/// The remaining data found at key `settings` in the overall data, +/// described by type `PerAccountSettingsState`, lives on the server +/// in the same way as the data under the keys in `cacheKeys`, +/// and so is ignored here. +@JsonSerializable() +class LegacyAppGlobalSettingsState { + final String language; + final LegacyAppThemeSetting theme; + final LegacyAppBrowserPreference browser; + + // Ignored because the legacy app hadn't used it since 2017. + // See discussion in commit zulip-mobile@761e3edb4 (from 2018). + // final bool experimentalFeaturesEnabled; // ignore + + final LegacyAppMarkMessagesReadOnScroll markMessagesReadOnScroll; + + LegacyAppGlobalSettingsState({ + required this.language, + required this.theme, + required this.browser, + required this.markMessagesReadOnScroll, + }); + + factory LegacyAppGlobalSettingsState.fromJson(Map json) => + _$LegacyAppGlobalSettingsStateFromJson(json); + + Map toJson() => _$LegacyAppGlobalSettingsStateToJson(this); +} + +/// Corresponds to type `ThemeSetting` in src/reduxTypes.js . +enum LegacyAppThemeSetting { + @JsonValue('default') + default_, + night; +} + +/// Corresponds to type `BrowserPreference` in src/reduxTypes.js . +enum LegacyAppBrowserPreference { + embedded, + external, + @JsonValue('default') + default_, +} + +/// Corresponds to the type `GlobalSettingsState['markMessagesReadOnScroll']` +/// in src/reduxTypes.js . +@JsonEnum(fieldRename: FieldRename.kebab) +enum LegacyAppMarkMessagesReadOnScroll { + always, never, conversationViewsOnly, +} + +/// Corresponds to type `Account` in src/types.js . +@JsonSerializable() +class LegacyAppAccount { + // These three come from type Auth in src/api/transportTypes.js . + @_LegacyAppUrlJsonConverter() + final Uri realm; + final String apiKey; + final String email; + + final int? userId; + + @_LegacyAppZulipVersionJsonConverter() + final String? zulipVersion; + + final int? zulipFeatureLevel; + + final String? ackedPushToken; + + // These three are ignored because this app doesn't currently have such + // notices or banners for them to control; and because if we later introduce + // such things, it's a pretty mild glitch to have them reappear, once, + // after a once-in-N-years major upgrade to the app. + // final DateTime? lastDismissedServerPushSetupNotice; // ignore + // final DateTime? lastDismissedServerNotifsExpiringBanner; // ignore + // final bool silenceServerPushSetupWarnings; // ignore + + LegacyAppAccount({ + required this.realm, + required this.apiKey, + required this.email, + required this.userId, + required this.zulipVersion, + required this.zulipFeatureLevel, + required this.ackedPushToken, + }); + + factory LegacyAppAccount.fromJson(Map json) => + _$LegacyAppAccountFromJson(json); + + Map toJson() => _$LegacyAppAccountToJson(this); +} + +/// This and its subclasses correspond to portions of src/storage/replaceRevive.js . +/// +/// (The rest of the conversions in that file are for types that don't appear +/// in the portions of the legacy app's state we care about.) +sealed class _LegacyAppJsonConverter extends JsonConverter> { + const _LegacyAppJsonConverter(); + + String get serializedTypeName; + + T fromJsonData(Object? json); + + Object? toJsonData(T value); + + /// Corresponds to `SERIALIZED_TYPE_FIELD_NAME`. + static const _serializedTypeFieldName = '__serializedType__'; + + @override + T fromJson(Map json) { + final actualTypeName = json[_serializedTypeFieldName]; + if (actualTypeName != serializedTypeName) { + throw FormatException("unexpected $_serializedTypeFieldName: $actualTypeName"); + } + return fromJsonData(json['data']); + } + + @override + Map toJson(T object) { + return { + _serializedTypeFieldName: serializedTypeName, + 'data': toJsonData(object), + }; + } +} + +class _LegacyAppUrlJsonConverter extends _LegacyAppJsonConverter { + const _LegacyAppUrlJsonConverter(); + + @override + String get serializedTypeName => 'URL'; + + @override + Uri fromJsonData(Object? json) => Uri.parse(json as String); + + @override + Object? toJsonData(Uri value) => value.toString(); +} + +/// Corresponds to type `ZulipVersion`. +/// +/// This new app skips the parsing logic of the legacy app's ZulipVersion type, +/// and just uses the raw string. +class _LegacyAppZulipVersionJsonConverter extends _LegacyAppJsonConverter { + const _LegacyAppZulipVersionJsonConverter(); + + @override + String get serializedTypeName => 'ZulipVersion'; + + @override + String fromJsonData(Object? json) => json as String; + + @override + Object? toJsonData(String value) => value; +} diff --git a/lib/model/legacy_app_data.g.dart b/lib/model/legacy_app_data.g.dart new file mode 100644 index 0000000000..e619745e38 --- /dev/null +++ b/lib/model/legacy_app_data.g.dart @@ -0,0 +1,116 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'legacy_app_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LegacyAppData _$LegacyAppDataFromJson(Map json) => + LegacyAppData( + settings: json['settings'] == null + ? null + : LegacyAppGlobalSettingsState.fromJson( + json['settings'] as Map, + ), + accounts: (json['accounts'] as List?) + ?.map((e) => LegacyAppAccount.fromJson(e as Map)) + .toList(), + ); + +Map _$LegacyAppDataToJson(LegacyAppData instance) => + { + 'settings': instance.settings, + 'accounts': instance.accounts, + }; + +LegacyAppMigrationsState _$LegacyAppMigrationsStateFromJson( + Map json, +) => LegacyAppMigrationsState(version: (json['version'] as num?)?.toInt()); + +Map _$LegacyAppMigrationsStateToJson( + LegacyAppMigrationsState instance, +) => {'version': instance.version}; + +LegacyAppGlobalSettingsState _$LegacyAppGlobalSettingsStateFromJson( + Map json, +) => LegacyAppGlobalSettingsState( + language: json['language'] as String, + theme: $enumDecode(_$LegacyAppThemeSettingEnumMap, json['theme']), + browser: $enumDecode(_$LegacyAppBrowserPreferenceEnumMap, json['browser']), + markMessagesReadOnScroll: $enumDecode( + _$LegacyAppMarkMessagesReadOnScrollEnumMap, + json['markMessagesReadOnScroll'], + ), +); + +Map _$LegacyAppGlobalSettingsStateToJson( + LegacyAppGlobalSettingsState instance, +) => { + 'language': instance.language, + 'theme': _$LegacyAppThemeSettingEnumMap[instance.theme]!, + 'browser': _$LegacyAppBrowserPreferenceEnumMap[instance.browser]!, + 'markMessagesReadOnScroll': + _$LegacyAppMarkMessagesReadOnScrollEnumMap[instance + .markMessagesReadOnScroll]!, +}; + +const _$LegacyAppThemeSettingEnumMap = { + LegacyAppThemeSetting.default_: 'default', + LegacyAppThemeSetting.night: 'night', +}; + +const _$LegacyAppBrowserPreferenceEnumMap = { + LegacyAppBrowserPreference.embedded: 'embedded', + LegacyAppBrowserPreference.external: 'external', + LegacyAppBrowserPreference.default_: 'default', +}; + +const _$LegacyAppMarkMessagesReadOnScrollEnumMap = { + LegacyAppMarkMessagesReadOnScroll.always: 'always', + LegacyAppMarkMessagesReadOnScroll.never: 'never', + LegacyAppMarkMessagesReadOnScroll.conversationViewsOnly: + 'conversation-views-only', +}; + +LegacyAppAccount _$LegacyAppAccountFromJson(Map json) => + LegacyAppAccount( + realm: const _LegacyAppUrlJsonConverter().fromJson( + json['realm'] as Map, + ), + apiKey: json['apiKey'] as String, + email: json['email'] as String, + userId: (json['userId'] as num?)?.toInt(), + zulipVersion: _$JsonConverterFromJson, String>( + json['zulipVersion'], + const _LegacyAppZulipVersionJsonConverter().fromJson, + ), + zulipFeatureLevel: (json['zulipFeatureLevel'] as num?)?.toInt(), + ackedPushToken: json['ackedPushToken'] as String?, + ); + +Map _$LegacyAppAccountToJson(LegacyAppAccount instance) => + { + 'realm': const _LegacyAppUrlJsonConverter().toJson(instance.realm), + 'apiKey': instance.apiKey, + 'email': instance.email, + 'userId': instance.userId, + 'zulipVersion': _$JsonConverterToJson, String>( + instance.zulipVersion, + const _LegacyAppZulipVersionJsonConverter().toJson, + ), + 'zulipFeatureLevel': instance.zulipFeatureLevel, + 'ackedPushToken': instance.ackedPushToken, + }; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => value == null ? null : toJson(value); From e140bbb8b366806db8f58412ec130b06ab4a144b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 12 Jun 2025 22:24:17 -0700 Subject: [PATCH 213/290] legacy-data: Describe where legacy data is stored and how encoded --- lib/model/legacy_app_data.dart | 172 +++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/lib/model/legacy_app_data.dart b/lib/model/legacy_app_data.dart index 98886ed4f4..74cc971f32 100644 --- a/lib/model/legacy_app_data.dart +++ b/lib/model/legacy_app_data.dart @@ -6,10 +6,182 @@ // TODO(#1593): write tests for this file library; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqlite3/sqlite3.dart'; part 'legacy_app_data.g.dart'; +Future readLegacyAppData() async { + final LegacyAppDatabase db; + try { + final sqlDb = sqlite3.open(await LegacyAppDatabase._filename()); + + // For writing tests (but more refactoring needed): + // sqlDb = sqlite3.openInMemory(); + + db = LegacyAppDatabase(sqlDb); + } catch (_) { + // Presumably the legacy database just doesn't exist, + // e.g. because this is a fresh install, not an upgrade from the legacy app. + return null; + } + + try { + if (db.migrationVersion() != 1) { + // The data is ancient. + return null; // TODO(log) + } + + final migrationsState = db.getDecodedItem('reduxPersist:migrations', + LegacyAppMigrationsState.fromJson); + final migrationsVersion = migrationsState?.version; + if (migrationsVersion == null) { + // The data never got written in the first place, + // at least not coherently. + return null; // TODO(log) + } + if (migrationsVersion < 58) { + // The data predates a migration that affected data we'll try to read. + // Namely migration 58, from commit 49ed2ef5d, PR #5656, 2023-02. + return null; // TODO(log) + } + if (migrationsVersion > 66) { + // The data is from a future schema version this app is unaware of. + return null; // TODO(log) + } + + final settingsStr = db.getItem('reduxPersist:settings'); + final accountsStr = db.getItem('reduxPersist:accounts'); + try { + return LegacyAppData.fromJson({ + 'settings': settingsStr == null ? null : jsonDecode(settingsStr), + 'accounts': accountsStr == null ? null : jsonDecode(accountsStr), + }); + } catch (_) { + return null; // TODO(log) + } + } on SqliteException { + return null; // TODO(log) + } +} + +class LegacyAppDatabase { + LegacyAppDatabase(this._db); + + final Database _db; + + static Future _filename() async { + const baseName = 'zulip.db'; // from AsyncStorageImpl._initDb + + final dir = await switch (defaultTargetPlatform) { + // See node_modules/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteModule.kt + // and the method SQLiteModule.pathForDatabaseName there: + // works out to "${mContext.filesDir}/SQLite/$name", + // so starting from: + // https://developer.android.com/reference/kotlin/android/content/Context#getFilesDir() + // That's what path_provider's getApplicationSupportDirectory gives. + // (The latter actually has a fallback when Android's getFilesDir + // returns null. But the Android docs say that can't happen. If it does, + // SQLiteModule would just fail to make a database, and the legacy app + // wouldn't have managed to store anything in the first place.) + TargetPlatform.android => getApplicationSupportDirectory(), + + // See node_modules/expo-sqlite/ios/EXSQLite/EXSQLite.m + // and the method `pathForDatabaseName:` there: + // works out to "${fileSystem.documentDirectory}/SQLite/$name", + // The base directory there comes from: + // node_modules/expo-modules-core/ios/Interfaces/FileSystem/EXFileSystemInterface.h + // node_modules/expo-file-system/ios/EXFileSystem/EXFileSystem.m + // so ultimately from an expression: + // NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) + // which means here: + // https://developer.apple.com/documentation/foundation/nssearchpathfordirectoriesindomains(_:_:_:)?language=objc + // https://developer.apple.com/documentation/foundation/filemanager/searchpathdirectory/documentdirectory?language=objc + // That's what path_provider's getApplicationDocumentsDirectory gives. + TargetPlatform.iOS => getApplicationDocumentsDirectory(), + + // On other platforms, there is no Zulip legacy app that this app replaces. + // So there's nothing to migrate. + _ => throw Exception(), + }; + + return '${dir.path}/SQLite/$baseName'; + } + + /// The migration version of the AsyncStorage database as a whole + /// (not to be confused with the version within `state.migrations`). + /// + /// This is always 1 since it was introduced, + /// in commit caf3bf999 in 2022-04. + /// + /// Corresponds to portions of AsyncStorageImpl._migrate . + int migrationVersion() { + final rows = _db.select('SELECT version FROM migration LIMIT 1'); + return rows.single.values.single as int; + } + + T? getDecodedItem(String key, T Function(Map) fromJson) { + final valueStr = getItem(key); + if (valueStr == null) return null; + + try { + return fromJson(jsonDecode(valueStr) as Map); + } catch (_) { + return null; // TODO(log) + } + } + + /// Corresponds to CompressedAsyncStorage.getItem. + String? getItem(String key) { + final item = getItemRaw(key); + if (item == null) return null; + if (item.startsWith('z')) { + // A leading 'z' marks Zulip compression. + // (It can't be the original uncompressed value, because all our values + // are JSON, and no JSON encoding starts with a 'z'.) + + if (defaultTargetPlatform != TargetPlatform.android) { + return null; // TODO(log) + } + + /// Corresponds to `header` in android/app/src/main/java/com/zulipmobile/TextCompression.kt . + const header = 'z|zlib base64|'; + if (!item.startsWith(header)) { + return null; // TODO(log) + } + + // These steps correspond to `decompress` in android/app/src/main/java/com/zulipmobile/TextCompression.kt . + final encodedSplit = item.substring(header.length); + // Not sure how newlines get there into the data; but empirically + // they do, after each 76 characters of `encodedSplit`. + final encoded = encodedSplit.replaceAll('\n', ''); + final compressedBytes = base64Decode(encoded); + final uncompressedBytes = zlib.decoder.convert(compressedBytes); + return utf8.decode(uncompressedBytes); + } + return item; + } + + /// Corresponds to AsyncStorageImpl.getItem. + String? getItemRaw(String key) { + final rows = _db.select('SELECT value FROM keyvalue WHERE key = ?', [key]); + final row = rows.firstOrNull; + if (row == null) return null; + return row.values.single as String; + } + + /// Corresponds to AsyncStorageImpl.getAllKeys. + List getAllKeys() { + final rows = _db.select('SELECT key FROM keyvalue'); + return [for (final r in rows) r.values.single as String]; + } +} + /// Represents the data from the legacy app's database, /// so far as it's relevant for this app. /// From 6dc5a80d740d3ccd5ed3dc49c37b6da8c25b9977 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 12 Jun 2025 23:10:39 -0700 Subject: [PATCH 214/290] legacy-data: Use legacy data to initialize this app's data Fixes #1070. --- lib/model/database.dart | 2 + lib/model/legacy_app_data.dart | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/lib/model/database.dart b/lib/model/database.dart index 33b877dce9..2811ae27d2 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -4,6 +4,7 @@ import 'package:drift/remote.dart'; import 'package:sqlite3/common.dart'; import '../log.dart'; +import 'legacy_app_data.dart'; import 'schema_versions.g.dart'; import 'settings.dart'; @@ -195,6 +196,7 @@ class AppDatabase extends _$AppDatabase { await m.createAll(); // Corresponds to `from4to5` above. await into(globalSettings).insert(GlobalSettingsCompanion()); + await migrateLegacyAppData(this); } @override diff --git a/lib/model/legacy_app_data.dart b/lib/model/legacy_app_data.dart index 74cc971f32..f3b94395ff 100644 --- a/lib/model/legacy_app_data.dart +++ b/lib/model/legacy_app_data.dart @@ -9,13 +9,105 @@ library; import 'dart:convert'; import 'dart:io'; +import 'package:drift/drift.dart' as drift; import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sqlite3/sqlite3.dart'; +import '../log.dart'; +import 'database.dart'; +import 'settings.dart'; + part 'legacy_app_data.g.dart'; +Future migrateLegacyAppData(AppDatabase db) async { + assert(debugLog("Migrating legacy app data...")); + final legacyData = await readLegacyAppData(); + if (legacyData == null) { + assert(debugLog("... no legacy app data found.")); + return; + } + + assert(debugLog("Found settings: ${legacyData.settings?.toJson()}")); + final settings = legacyData.settings; + if (settings != null) { + await db.update(db.globalSettings).write(GlobalSettingsCompanion( + // TODO(#1139) apply settings.language + themeSetting: switch (settings.theme) { + // The legacy app has just two values for this setting: light and dark, + // where light is the default. Map that default to the new default, + // which is to follow the system-wide setting. + // We planned the same change for the legacy app (but were + // foiled by React Native): + // https://github.com/zulip/zulip-mobile/issues/5533 + // More-recent discussion: + // https://github.com/zulip/zulip-flutter/pull/1588#discussion_r2147418577 + LegacyAppThemeSetting.default_ => drift.Value.absent(), + LegacyAppThemeSetting.night => drift.Value(ThemeSetting.dark), + }, + browserPreference: switch (settings.browser) { + LegacyAppBrowserPreference.embedded => drift.Value(BrowserPreference.inApp), + LegacyAppBrowserPreference.external => drift.Value(BrowserPreference.external), + LegacyAppBrowserPreference.default_ => drift.Value.absent(), + }, + markReadOnScroll: switch (settings.markMessagesReadOnScroll) { + // The legacy app's default was "always". + // In this app, that would mix poorly with the VisitFirstUnreadSetting + // default of "conversations"; so translate the old default + // to the new default of "conversations". + LegacyAppMarkMessagesReadOnScroll.always => + drift.Value(MarkReadOnScrollSetting.conversations), + LegacyAppMarkMessagesReadOnScroll.never => + drift.Value(MarkReadOnScrollSetting.never), + LegacyAppMarkMessagesReadOnScroll.conversationViewsOnly => + drift.Value(MarkReadOnScrollSetting.conversations), + }, + )); + } + + assert(debugLog("Found ${legacyData.accounts?.length} accounts:")); + for (final account in legacyData.accounts ?? []) { + assert(debugLog(" account: ${account.toJson()..['apiKey'] = 'redacted'}")); + if (account.apiKey.isEmpty) { + // This represents the user having logged out of this account. + // (See `Auth.apiKey` in src/api/transportTypes.js .) + // In this app, when a user logs out of an account, + // the account is removed from the accounts list. So remove this account. + assert(debugLog(" (account ignored because had been logged out)")); + continue; + } + if (account.userId == null + || account.zulipVersion == null + || account.zulipFeatureLevel == null) { + // The legacy app either never loaded server data for this account, + // or last did so on an ancient version of the app. + // (See docs and comments on these properties in src/types.js . + // Specifically, the latest added of these was userId, in commit 4fdefb09b + // (#M4968), released in v27.170 in 2021-09.) + // Drop the account. + assert(debugLog(" (account ignored because missing metadata)")); + continue; + } + await db.createAccount(AccountsCompanion.insert( + realmUrl: account.realm, + userId: account.userId!, + email: account.email, + apiKey: account.apiKey, + zulipVersion: account.zulipVersion!, + // no zulipMergeBase; legacy app didn't record it + zulipFeatureLevel: account.zulipFeatureLevel!, + // This app doesn't yet maintain ackedPushToken (#322), so avoid recording + // a value that would then be allowed to get stale. See discussion: + // https://github.com/zulip/zulip-flutter/pull/1588#discussion_r2148817025 + // TODO(#322): apply ackedPushToken + // ackedPushToken: drift.Value(account.ackedPushToken), + )); + } + + assert(debugLog("Done migrating legacy app data.")); +} + Future readLegacyAppData() async { final LegacyAppDatabase db; try { From 21bb1b5925437591e3dba0572f2f3de0481d3aa5 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 14 Jun 2025 22:32:05 -0700 Subject: [PATCH 215/290] legacy-data: Record whether legacy-app data was found --- lib/model/database.dart | 15 +- lib/model/database.g.dart | 106 ++- lib/model/legacy_app_data.dart | 8 + lib/model/schema_versions.g.dart | 86 ++ lib/model/settings.dart | 23 + test/model/database_test.dart | 2 + test/model/schemas/drift_schema_v9.json | 1 + test/model/schemas/schema.dart | 5 +- test/model/schemas/schema_v9.dart | 1014 +++++++++++++++++++++++ 9 files changed, 1256 insertions(+), 4 deletions(-) create mode 100644 test/model/schemas/drift_schema_v9.json create mode 100644 test/model/schemas/schema_v9.dart diff --git a/lib/model/database.dart b/lib/model/database.dart index 2811ae27d2..ca84fc949c 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -31,6 +31,9 @@ class GlobalSettings extends Table { Column get markReadOnScroll => textEnum() .nullable()(); + Column get legacyUpgradeState => textEnum() + .nullable()(); + // If adding a new column to this table, consider whether [BoolGlobalSettings] // can do the job instead (by adding a value to the [BoolGlobalSetting] enum). // That way is more convenient, when it works, because @@ -126,7 +129,7 @@ class AppDatabase extends _$AppDatabase { // information on using the build_runner. // * Write a migration in `_migrationSteps` below. // * Write tests. - static const int latestSchemaVersion = 8; // See note. + static const int latestSchemaVersion = 9; // See note. @override int get schemaVersion => latestSchemaVersion; @@ -189,6 +192,15 @@ class AppDatabase extends _$AppDatabase { await m.addColumn(schema.globalSettings, schema.globalSettings.markReadOnScroll); }, + from8To9: (m, schema) async { + await m.addColumn(schema.globalSettings, + schema.globalSettings.legacyUpgradeState); + // Earlier versions of this app weren't built to be installed over + // the legacy app. So if upgrading from an earlier version of this app, + // assume there wasn't also the legacy app before that. + await m.database.update(schema.globalSettings).write( + RawValuesInsertable({'legacy_upgrade_state': Constant('noLegacy')})); + } ); Future _createLatestSchema(Migrator m) async { @@ -196,6 +208,7 @@ class AppDatabase extends _$AppDatabase { await m.createAll(); // Corresponds to `from4to5` above. await into(globalSettings).insert(GlobalSettingsCompanion()); + // Corresponds to (but differs from) part of `from8To9` above. await migrateLegacyAppData(this); } diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart index d78f7ede84..6fdbec74f8 100644 --- a/lib/model/database.g.dart +++ b/lib/model/database.g.dart @@ -57,11 +57,24 @@ class $GlobalSettingsTable extends GlobalSettings $GlobalSettingsTable.$convertermarkReadOnScrolln, ); @override + late final GeneratedColumnWithTypeConverter + legacyUpgradeState = + GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $GlobalSettingsTable.$converterlegacyUpgradeStaten, + ); + @override List get $columns => [ themeSetting, browserPreference, visitFirstUnread, markReadOnScroll, + legacyUpgradeState, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -101,6 +114,13 @@ class $GlobalSettingsTable extends GlobalSettings data['${effectivePrefix}mark_read_on_scroll'], ), ), + legacyUpgradeState: $GlobalSettingsTable.$converterlegacyUpgradeStaten + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}legacy_upgrade_state'], + ), + ), ); } @@ -141,6 +161,14 @@ class $GlobalSettingsTable extends GlobalSettings $convertermarkReadOnScrolln = JsonTypeConverter2.asNullable( $convertermarkReadOnScroll, ); + static JsonTypeConverter2 + $converterlegacyUpgradeState = const EnumNameConverter( + LegacyUpgradeState.values, + ); + static JsonTypeConverter2 + $converterlegacyUpgradeStaten = JsonTypeConverter2.asNullable( + $converterlegacyUpgradeState, + ); } class GlobalSettingsData extends DataClass @@ -149,11 +177,13 @@ class GlobalSettingsData extends DataClass final BrowserPreference? browserPreference; final VisitFirstUnreadSetting? visitFirstUnread; final MarkReadOnScrollSetting? markReadOnScroll; + final LegacyUpgradeState? legacyUpgradeState; const GlobalSettingsData({ this.themeSetting, this.browserPreference, this.visitFirstUnread, this.markReadOnScroll, + this.legacyUpgradeState, }); @override Map toColumns(bool nullToAbsent) { @@ -184,6 +214,13 @@ class GlobalSettingsData extends DataClass ), ); } + if (!nullToAbsent || legacyUpgradeState != null) { + map['legacy_upgrade_state'] = Variable( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toSql( + legacyUpgradeState, + ), + ); + } return map; } @@ -201,6 +238,9 @@ class GlobalSettingsData extends DataClass markReadOnScroll: markReadOnScroll == null && nullToAbsent ? const Value.absent() : Value(markReadOnScroll), + legacyUpgradeState: legacyUpgradeState == null && nullToAbsent + ? const Value.absent() + : Value(legacyUpgradeState), ); } @@ -219,6 +259,8 @@ class GlobalSettingsData extends DataClass .fromJson(serializer.fromJson(json['visitFirstUnread'])), markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln .fromJson(serializer.fromJson(json['markReadOnScroll'])), + legacyUpgradeState: $GlobalSettingsTable.$converterlegacyUpgradeStaten + .fromJson(serializer.fromJson(json['legacyUpgradeState'])), ); } @override @@ -243,6 +285,11 @@ class GlobalSettingsData extends DataClass markReadOnScroll, ), ), + 'legacyUpgradeState': serializer.toJson( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toJson( + legacyUpgradeState, + ), + ), }; } @@ -251,6 +298,7 @@ class GlobalSettingsData extends DataClass Value browserPreference = const Value.absent(), Value visitFirstUnread = const Value.absent(), Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, browserPreference: browserPreference.present @@ -262,6 +310,9 @@ class GlobalSettingsData extends DataClass markReadOnScroll: markReadOnScroll.present ? markReadOnScroll.value : this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState.present + ? legacyUpgradeState.value + : this.legacyUpgradeState, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( @@ -277,6 +328,9 @@ class GlobalSettingsData extends DataClass markReadOnScroll: data.markReadOnScroll.present ? data.markReadOnScroll.value : this.markReadOnScroll, + legacyUpgradeState: data.legacyUpgradeState.present + ? data.legacyUpgradeState.value + : this.legacyUpgradeState, ); } @@ -286,7 +340,8 @@ class GlobalSettingsData extends DataClass ..write('themeSetting: $themeSetting, ') ..write('browserPreference: $browserPreference, ') ..write('visitFirstUnread: $visitFirstUnread, ') - ..write('markReadOnScroll: $markReadOnScroll') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState') ..write(')')) .toString(); } @@ -297,6 +352,7 @@ class GlobalSettingsData extends DataClass browserPreference, visitFirstUnread, markReadOnScroll, + legacyUpgradeState, ); @override bool operator ==(Object other) => @@ -305,7 +361,8 @@ class GlobalSettingsData extends DataClass other.themeSetting == this.themeSetting && other.browserPreference == this.browserPreference && other.visitFirstUnread == this.visitFirstUnread && - other.markReadOnScroll == this.markReadOnScroll); + other.markReadOnScroll == this.markReadOnScroll && + other.legacyUpgradeState == this.legacyUpgradeState); } class GlobalSettingsCompanion extends UpdateCompanion { @@ -313,12 +370,14 @@ class GlobalSettingsCompanion extends UpdateCompanion { final Value browserPreference; final Value visitFirstUnread; final Value markReadOnScroll; + final Value legacyUpgradeState; final Value rowid; const GlobalSettingsCompanion({ this.themeSetting = const Value.absent(), this.browserPreference = const Value.absent(), this.visitFirstUnread = const Value.absent(), this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), this.rowid = const Value.absent(), }); GlobalSettingsCompanion.insert({ @@ -326,6 +385,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { this.browserPreference = const Value.absent(), this.visitFirstUnread = const Value.absent(), this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), this.rowid = const Value.absent(), }); static Insertable custom({ @@ -333,6 +393,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { Expression? browserPreference, Expression? visitFirstUnread, Expression? markReadOnScroll, + Expression? legacyUpgradeState, Expression? rowid, }) { return RawValuesInsertable({ @@ -340,6 +401,8 @@ class GlobalSettingsCompanion extends UpdateCompanion { if (browserPreference != null) 'browser_preference': browserPreference, if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (legacyUpgradeState != null) + 'legacy_upgrade_state': legacyUpgradeState, if (rowid != null) 'rowid': rowid, }); } @@ -349,6 +412,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { Value? browserPreference, Value? visitFirstUnread, Value? markReadOnScroll, + Value? legacyUpgradeState, Value? rowid, }) { return GlobalSettingsCompanion( @@ -356,6 +420,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { browserPreference: browserPreference ?? this.browserPreference, visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState ?? this.legacyUpgradeState, rowid: rowid ?? this.rowid, ); } @@ -389,6 +454,13 @@ class GlobalSettingsCompanion extends UpdateCompanion { ), ); } + if (legacyUpgradeState.present) { + map['legacy_upgrade_state'] = Variable( + $GlobalSettingsTable.$converterlegacyUpgradeStaten.toSql( + legacyUpgradeState.value, + ), + ); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -402,6 +474,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { ..write('browserPreference: $browserPreference, ') ..write('visitFirstUnread: $visitFirstUnread, ') ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -1248,6 +1321,7 @@ typedef $$GlobalSettingsTableCreateCompanionBuilder = Value browserPreference, Value visitFirstUnread, Value markReadOnScroll, + Value legacyUpgradeState, Value rowid, }); typedef $$GlobalSettingsTableUpdateCompanionBuilder = @@ -1256,6 +1330,7 @@ typedef $$GlobalSettingsTableUpdateCompanionBuilder = Value browserPreference, Value visitFirstUnread, Value markReadOnScroll, + Value legacyUpgradeState, Value rowid, }); @@ -1299,6 +1374,16 @@ class $$GlobalSettingsTableFilterComposer column: $table.markReadOnScroll, builder: (column) => ColumnWithTypeConverterFilters(column), ); + + ColumnWithTypeConverterFilters< + LegacyUpgradeState?, + LegacyUpgradeState, + String + > + get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); } class $$GlobalSettingsTableOrderingComposer @@ -1329,6 +1414,11 @@ class $$GlobalSettingsTableOrderingComposer column: $table.markReadOnScroll, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => ColumnOrderings(column), + ); } class $$GlobalSettingsTableAnnotationComposer @@ -1363,6 +1453,12 @@ class $$GlobalSettingsTableAnnotationComposer column: $table.markReadOnScroll, builder: (column) => column, ); + + GeneratedColumnWithTypeConverter + get legacyUpgradeState => $composableBuilder( + column: $table.legacyUpgradeState, + builder: (column) => column, + ); } class $$GlobalSettingsTableTableManager @@ -1409,12 +1505,15 @@ class $$GlobalSettingsTableTableManager const Value.absent(), Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = + const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion( themeSetting: themeSetting, browserPreference: browserPreference, visitFirstUnread: visitFirstUnread, markReadOnScroll: markReadOnScroll, + legacyUpgradeState: legacyUpgradeState, rowid: rowid, ), createCompanionCallback: @@ -1426,12 +1525,15 @@ class $$GlobalSettingsTableTableManager const Value.absent(), Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = + const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion.insert( themeSetting: themeSetting, browserPreference: browserPreference, visitFirstUnread: visitFirstUnread, markReadOnScroll: markReadOnScroll, + legacyUpgradeState: legacyUpgradeState, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/model/legacy_app_data.dart b/lib/model/legacy_app_data.dart index f3b94395ff..215248c776 100644 --- a/lib/model/legacy_app_data.dart +++ b/lib/model/legacy_app_data.dart @@ -26,10 +26,12 @@ Future migrateLegacyAppData(AppDatabase db) async { final legacyData = await readLegacyAppData(); if (legacyData == null) { assert(debugLog("... no legacy app data found.")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.noLegacy); return; } assert(debugLog("Found settings: ${legacyData.settings?.toJson()}")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.found); final settings = legacyData.settings; if (settings != null) { await db.update(db.globalSettings).write(GlobalSettingsCompanion( @@ -106,6 +108,12 @@ Future migrateLegacyAppData(AppDatabase db) async { } assert(debugLog("Done migrating legacy app data.")); + await _setLegacyUpgradeState(db, LegacyUpgradeState.migrated); +} + +Future _setLegacyUpgradeState(AppDatabase db, LegacyUpgradeState value) async { + await db.update(db.globalSettings).write(GlobalSettingsCompanion( + legacyUpgradeState: drift.Value(value))); } Future readLegacyAppData() async { diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart index 5712a94fbb..782b9409e2 100644 --- a/lib/model/schema_versions.g.dart +++ b/lib/model/schema_versions.g.dart @@ -517,6 +517,84 @@ i1.GeneratedColumn _column_14(String aliasedName) => true, type: i1.DriftSqlType.string, ); + +final class Schema9 extends i0.VersionedSchema { + Schema9({required super.database}) : super(version: 9); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape6 globalSettings = Shape6( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14, _column_15], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + 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 Shape6 extends i0.VersionedTable { + Shape6({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; + i1.GeneratedColumn get markReadOnScroll => + columnsByName['mark_read_on_scroll']! as i1.GeneratedColumn; + i1.GeneratedColumn get legacyUpgradeState => + columnsByName['legacy_upgrade_state']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_15(String aliasedName) => + i1.GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -525,6 +603,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, required Future Function(i1.Migrator m, Schema8 schema) from7To8, + required Future Function(i1.Migrator m, Schema9 schema) from8To9, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -563,6 +642,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from7To8(migrator, schema); return 8; + case 8: + final schema = Schema9(database: database); + final migrator = i1.Migrator(database, schema); + await from8To9(migrator, schema); + return 9; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -577,6 +661,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, required Future Function(i1.Migrator m, Schema8 schema) from7To8, + required Future Function(i1.Migrator m, Schema9 schema) from8To9, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -586,5 +671,6 @@ i1.OnUpgrade stepByStep({ from5To6: from5To6, from6To7: from6To7, from7To8: from7To8, + from8To9: from8To9, ), ); diff --git a/lib/model/settings.dart b/lib/model/settings.dart index 298980a395..602eb35fb8 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -87,6 +87,24 @@ enum MarkReadOnScrollSetting { static MarkReadOnScrollSetting _default = conversations; } +/// The outcome, or in-progress status, of migrating data from the legacy app. +enum LegacyUpgradeState { + /// It's not yet known whether there was data from the legacy app. + unknown, + + /// No legacy data was found. + noLegacy, + + /// Legacy data was found, but not yet migrated into this app's database. + found, + + /// Legacy data was found and migrated. + migrated, + ; + + static LegacyUpgradeState _default = unknown; +} + /// A general category of account-independent setting the user might set. /// /// Different kinds of settings call for different treatment in the UI, @@ -324,6 +342,11 @@ class GlobalSettingsStore extends ChangeNotifier { }; } + /// The outcome, or in-progress status, of migrating data from the legacy app. + LegacyUpgradeState get legacyUpgradeState { + return _data.legacyUpgradeState ?? LegacyUpgradeState._default; + } + /// The user's choice of the given bool-valued setting, or our default for it. /// /// See also [setBool]. diff --git a/test/model/database_test.dart b/test/model/database_test.dart index e6e2b729be..e89bd569db 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -326,6 +326,8 @@ void main() { check(globalSettings.browserPreference).isNull(); await after.close(); }); + + // TODO(#1593) test upgrade to v9: legacyUpgradeState set to noLegacy }); } diff --git a/test/model/schemas/drift_schema_v9.json b/test/model/schemas/drift_schema_v9.json new file mode 100644 index 0000000000..e425bc89c8 --- /dev/null +++ b/test/model/schemas/drift_schema_v9.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}},{"name":"legacy_upgrade_state","getter_name":"legacyUpgradeState","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LegacyUpgradeState.values)","dart_type_name":"LegacyUpgradeState"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart index 746206e453..413b4408c4 100644 --- a/test/model/schemas/schema.dart +++ b/test/model/schemas/schema.dart @@ -11,6 +11,7 @@ import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; import 'schema_v8.dart' as v8; +import 'schema_v9.dart' as v9; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -32,10 +33,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v7.DatabaseAtV7(db); case 8: return v8.DatabaseAtV8(db); + case 9: + return v9.DatabaseAtV9(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7, 8]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9]; } diff --git a/test/model/schemas/schema_v9.dart b/test/model/schemas/schema_v9.dart new file mode 100644 index 0000000000..d036e3a26f --- /dev/null +++ b/test/model/schemas/schema_v9.dart @@ -0,0 +1,1014 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn legacyUpgradeState = + GeneratedColumn( + 'legacy_upgrade_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + legacyUpgradeState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}legacy_upgrade_state'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + final String? legacyUpgradeState; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + this.legacyUpgradeState, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + if (!nullToAbsent || legacyUpgradeState != null) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + legacyUpgradeState: legacyUpgradeState == null && nullToAbsent + ? const Value.absent() + : Value(legacyUpgradeState), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + legacyUpgradeState: serializer.fromJson( + json['legacyUpgradeState'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + 'legacyUpgradeState': serializer.toJson(legacyUpgradeState), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + Value legacyUpgradeState = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState.present + ? legacyUpgradeState.value + : this.legacyUpgradeState, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + legacyUpgradeState: data.legacyUpgradeState.present + ? data.legacyUpgradeState.value + : this.legacyUpgradeState, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + legacyUpgradeState, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll && + other.legacyUpgradeState == this.legacyUpgradeState); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value legacyUpgradeState; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.legacyUpgradeState = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? legacyUpgradeState, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (legacyUpgradeState != null) + 'legacy_upgrade_state': legacyUpgradeState, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? legacyUpgradeState, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + legacyUpgradeState: legacyUpgradeState ?? this.legacyUpgradeState, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (legacyUpgradeState.present) { + map['legacy_upgrade_state'] = Variable(legacyUpgradeState.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('legacyUpgradeState: $legacyUpgradeState, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV9 extends GeneratedDatabase { + DatabaseAtV9(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 9; +} From 0e34d961fb2eb80cb652b1b18afe8e49946d80e9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 14 Jun 2025 23:07:05 -0700 Subject: [PATCH 216/290] welcome: Show a dialog on first upgrading from legacy app Fixes #1580. --- lib/model/settings.dart | 7 ++++ lib/widgets/app.dart | 1 + lib/widgets/dialog.dart | 70 +++++++++++++++++++++++++++++++++++ test/widgets/dialog_test.dart | 2 + 4 files changed, 80 insertions(+) diff --git a/lib/model/settings.dart b/lib/model/settings.dart index 602eb35fb8..81777d988d 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -119,6 +119,9 @@ enum GlobalSettingType { /// we give it a placeholder value which isn't a real setting. placeholder, + /// Describes a pseudo-setting not directly exposed in the UI. + internal, + /// Describes a setting which enables an in-progress feature of the app. /// /// Sometimes when building a complex feature it's useful to merge PRs that @@ -170,6 +173,10 @@ enum BoolGlobalSetting { /// (Having one stable value in this enum is also handy for tests.) placeholderIgnore(GlobalSettingType.placeholder, false), + /// A pseudo-setting recording whether the user has been shown the + /// welcome dialog for upgrading from the legacy app. + upgradeWelcomeDialogShown(GlobalSettingType.internal, false), + /// An experimental flag to toggle rendering KaTeX content in messages. renderKatex(GlobalSettingType.experimentalFeatureFlag, false), diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index b1aa763ac8..d3ed5c463d 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -160,6 +160,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + UpgradeWelcomeDialog.maybeShow(); } @override diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 4d269cddba..e635071cc9 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/settings.dart'; import 'actions.dart'; +import 'app.dart'; +import 'content.dart'; +import 'store.dart'; Widget _dialogActionText(String text) { return Text( @@ -112,3 +116,69 @@ DialogStatus showSuggestedActionDialog({ ])); return DialogStatus(future); } + +/// A brief dialog box welcoming the user to this new Zulip app, +/// shown upon upgrading from the legacy app. +class UpgradeWelcomeDialog extends StatelessWidget { + const UpgradeWelcomeDialog._(); + + static void maybeShow() async { + final 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 globalSettings = GlobalStoreWidget.settingsOf(context); + switch (globalSettings.legacyUpgradeState) { + case LegacyUpgradeState.noLegacy: + // This install didn't replace the legacy app. + return; + + case LegacyUpgradeState.unknown: + // Not clear if this replaced the legacy app; + // skip the dialog that would assume it had. + // TODO(log) + return; + + case LegacyUpgradeState.found: + case LegacyUpgradeState.migrated: + // This install replaced the legacy app. + // Show the dialog, if we haven't already. + if (globalSettings.getBool(BoolGlobalSetting.upgradeWelcomeDialogShown)) { + return; + } + } + + final future = showDialog( + context: context, + builder: (context) => UpgradeWelcomeDialog._()); + + await future; // Wait for the dialog to be dismissed. + + await globalSettings.setBool(BoolGlobalSetting.upgradeWelcomeDialogShown, true); + } + + static const String _announcementUrl = + 'https://blog.zulip.com/flutter-mobile-app-launch'; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return AlertDialog( + title: Text(zulipLocalizations.upgradeWelcomeDialogTitle), + content: SingleChildScrollView( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(zulipLocalizations.upgradeWelcomeDialogMessage), + GestureDetector( + onTap: () => PlatformActions.launchUrl(context, + Uri.parse(_announcementUrl)), + child: Text( + style: TextStyle(color: ContentTheme.of(context).colorLink), + zulipLocalizations.upgradeWelcomeDialogLinkText)), + ])), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), + child: Text(zulipLocalizations.upgradeWelcomeDialogDismiss)), + ]); + } +} diff --git a/test/widgets/dialog_test.dart b/test/widgets/dialog_test.dart index c86aae478e..1980f619f3 100644 --- a/test/widgets/dialog_test.dart +++ b/test/widgets/dialog_test.dart @@ -73,4 +73,6 @@ void main() { await check(dialog.result).completes((it) => it.equals(null)); }); }); + + // TODO(#1594): test UpgradeWelcomeDialog } From c750bdc9840e2de4be1cf85933fcae9af6368db2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 15 Jun 2025 19:21:07 -0700 Subject: [PATCH 217/290] legacy-data: Tolerate dupe account, proceeding to next account --- lib/model/legacy_app_data.dart | 40 ++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/lib/model/legacy_app_data.dart b/lib/model/legacy_app_data.dart index 215248c776..9c0f5faa26 100644 --- a/lib/model/legacy_app_data.dart +++ b/lib/model/legacy_app_data.dart @@ -91,20 +91,32 @@ Future migrateLegacyAppData(AppDatabase db) async { assert(debugLog(" (account ignored because missing metadata)")); continue; } - await db.createAccount(AccountsCompanion.insert( - realmUrl: account.realm, - userId: account.userId!, - email: account.email, - apiKey: account.apiKey, - zulipVersion: account.zulipVersion!, - // no zulipMergeBase; legacy app didn't record it - zulipFeatureLevel: account.zulipFeatureLevel!, - // This app doesn't yet maintain ackedPushToken (#322), so avoid recording - // a value that would then be allowed to get stale. See discussion: - // https://github.com/zulip/zulip-flutter/pull/1588#discussion_r2148817025 - // TODO(#322): apply ackedPushToken - // ackedPushToken: drift.Value(account.ackedPushToken), - )); + try { + await db.createAccount(AccountsCompanion.insert( + realmUrl: account.realm, + userId: account.userId!, + email: account.email, + apiKey: account.apiKey, + zulipVersion: account.zulipVersion!, + // no zulipMergeBase; legacy app didn't record it + zulipFeatureLevel: account.zulipFeatureLevel!, + // This app doesn't yet maintain ackedPushToken (#322), so avoid recording + // a value that would then be allowed to get stale. See discussion: + // https://github.com/zulip/zulip-flutter/pull/1588#discussion_r2148817025 + // TODO(#322): apply ackedPushToken + // ackedPushToken: drift.Value(account.ackedPushToken), + )); + } on AccountAlreadyExistsException { + // There's one known way this can actually happen: the legacy app doesn't + // prevent duplicates on (realm, userId), only on (realm, email). + // + // So if e.g. the user changed their email on an account at some point + // in the past, and didn't go and delete the old version from the + // list of accounts, then the old version (the one later in the list, + // since the legacy app orders accounts by recency) will get dropped here. + assert(debugLog(" (account ignored because duplicate)")); + continue; + } } assert(debugLog("Done migrating legacy app data.")); From 2209d038b6d8f89031cae756f905943580f0ef5e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 15 Jun 2025 17:26:41 -0700 Subject: [PATCH 218/290] legacy-data: Tolerate an item failing to decompress I believe the legacy app never actually leaves behind data that would trigger this. But if it does, avoid throwing an exception. --- lib/model/legacy_app_data.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/model/legacy_app_data.dart b/lib/model/legacy_app_data.dart index 9c0f5faa26..5f6197f0fc 100644 --- a/lib/model/legacy_app_data.dart +++ b/lib/model/legacy_app_data.dart @@ -272,9 +272,13 @@ class LegacyAppDatabase { // Not sure how newlines get there into the data; but empirically // they do, after each 76 characters of `encodedSplit`. final encoded = encodedSplit.replaceAll('\n', ''); - final compressedBytes = base64Decode(encoded); - final uncompressedBytes = zlib.decoder.convert(compressedBytes); - return utf8.decode(uncompressedBytes); + try { + final compressedBytes = base64Decode(encoded); + final uncompressedBytes = zlib.decoder.convert(compressedBytes); + return utf8.decode(uncompressedBytes); + } catch (_) { + return null; // TODO(log) + } } return item; } From c561c6130942a9fa344b8877d160343d319abfd2 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Mon, 16 Jun 2025 12:01:54 +0200 Subject: [PATCH 219/290] l10n: Update translations from Weblate. --- assets/l10n/app_it.arb | 16 ++++ assets/l10n/app_ru.arb | 16 ++++ assets/l10n/app_sl.arb | 76 ++++++++++++++++++- assets/l10n/app_zh_Hans_CN.arb | 16 ++++ .../l10n/zulip_localizations_it.dart | 8 +- .../l10n/zulip_localizations_ru.dart | 10 +-- .../l10n/zulip_localizations_sl.dart | 52 +++++++------ .../l10n/zulip_localizations_zh.dart | 12 +++ 8 files changed, 170 insertions(+), 36 deletions(-) diff --git a/assets/l10n/app_it.arb b/assets/l10n/app_it.arb index 8cf9473078..714ebfa225 100644 --- a/assets/l10n/app_it.arb +++ b/assets/l10n/app_it.arb @@ -1180,5 +1180,21 @@ "emojiPickerSearchEmoji": "Cerca emoji", "@emojiPickerSearchEmoji": { "description": "Hint text for the emoji picker search text field." + }, + "upgradeWelcomeDialogLinkText": "Date un'occhiata al post dell'annuncio sul blog!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Andiamo", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "Troverai un'esperienza familiare in un pacchetto più veloce ed elegante.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogTitle": "Benvenuti alla nuova app Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 465f339f0a..cf53185e2c 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1180,5 +1180,21 @@ "markReadOnScrollSettingConversationsDescription": "Сообщения будут автоматически помечаться как прочитанные только при просмотре отдельной темы или личной беседы.", "@markReadOnScrollSettingConversationsDescription": { "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogDismiss": "Приступим!", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "Вы найдете привычные возможности в более быстром и легком приложении.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogTitle": "Добро пожаловать в новое приложение Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Ознакомьтесь с анонсом в блоге!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." } } diff --git a/assets/l10n/app_sl.arb b/assets/l10n/app_sl.arb index cfa3cc89c5..e0f0ae9fc1 100644 --- a/assets/l10n/app_sl.arb +++ b/assets/l10n/app_sl.arb @@ -71,7 +71,7 @@ "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, - "markAsReadComplete": "Označeno je {num, plural, =1{1 sporočilo} one{2 sporočili} few{{num} sporočila} other{{num} sporočil}} kot prebrano.", + "markAsReadComplete": "Označeno je {num, plural, one{{num} sporočilo} two{{num} sporočili} few{{num} sporočila} other{{num} sporočil}} kot prebrano.", "@markAsReadComplete": { "description": "Message when marking messages as read has completed.", "placeholders": { @@ -919,7 +919,7 @@ "@recentDmConversationsEmptyPlaceholder": { "description": "Centered text on the 'Direct messages' page saying that there is no content to show." }, - "errorFilesTooLarge": "{num, plural, =1{Datoteka presega} one{Dve datoteki presegata} few{{num} datoteke presegajo} other{{num} datotek presega}} omejitev velikosti strežnika ({maxFileUploadSizeMib} MiB) in {num, plural, =1{ne bo naložena} one{ne bosta naloženi} few{ne bodo naložene} other{ne bodo naložene}}:\n\n{listMessage}", + "errorFilesTooLarge": "{num, plural, one{{num} datoteka presega} two{{num} datoteki presegata} few{{num} datoteke presegajo} other{{num} datotek presega}} omejitev velikosti strežnika ({maxFileUploadSizeMib} MiB) in {num, plural, one{ne bo naložena} two{ne bosta naloženi} few{ne bodo naložene} other{ne bodo naložene}}:\n\n{listMessage}", "@errorFilesTooLarge": { "description": "Error message when attached files are too large in size.", "placeholders": { @@ -1041,7 +1041,7 @@ "@actionSheetOptionUnmuteTopic": { "description": "Label for unmuting a topic on action sheet." }, - "errorFilesTooLargeTitle": "\"{num, plural, =1{Datoteka je prevelika} one{Dve datoteki sta preveliki} few{{num} datoteke so prevelike} other{{num} datotek je prevelikih}}\"", + "errorFilesTooLargeTitle": "\"{num, plural, one{{num} datoteka je prevelika} two{{num} datoteki sta preveliki} few{{num} datoteke so prevelike} other{{num} datotek je prevelikih}}\"", "@errorFilesTooLargeTitle": { "description": "Error title when attached files are too large in size.", "placeholders": { @@ -1051,7 +1051,7 @@ } } }, - "markAsUnreadComplete": "{num, plural, =1{Označeno je 1 sporočilo kot neprebrano} one{Označeni sta 2 sporočili kot neprebrani} few{Označena so {num} sporočila kot neprebrana} other{Označeno je {num} sporočil kot neprebranih}}.", + "markAsUnreadComplete": "{num, plural, one{Označeno je {num} sporočilo kot neprebrano} two{Označeni sta {num} sporočili kot neprebrani} few{Označena so {num} sporočila kot neprebrana} other{Označeno je {num} sporočil kot neprebranih}}.", "@markAsUnreadComplete": { "description": "Message when marking messages as unread has completed.", "placeholders": { @@ -1128,5 +1128,73 @@ "pinnedSubscriptionsLabel": "Pripeto", "@pinnedSubscriptionsLabel": { "description": "Label for the list of pinned subscribed channels." + }, + "initialAnchorSettingTitle": "Odpri tok sporočil pri", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadAlways": "Prvo neprebrano sporočilo", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingDescription": "Lahko izberete, ali se tok sporočil odpre pri vašem prvem neprebranem sporočilu ali pri najnovejših sporočilih.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingDescription": "Naj se sporočila ob pomikanju samodejno označijo kot prebrana?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogLinkText": "Preberite objavo na blogu!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "initialAnchorSettingNewestAlways": "Najnovejše sporočilo", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingAlways": "Vedno", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Samo v pogledih pogovorov", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "initialAnchorSettingFirstUnreadConversations": "Prvo neprebrano v zasebnih pogovorih, najnovejše drugje", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingTitle": "Ob pomikanju označi sporočila kot prebrana", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogTitle": "Dobrodošli v novi aplikaciji Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "markReadOnScrollSettingConversationsDescription": "Sporočila bodo samodejno označena kot prebrana samo pri ogledu ene teme ali zasebnega pogovora.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Nikoli", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogMessage": "Čaka vas znana izkušnja v hitrejši in bolj elegantni obliki.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Začnimo", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "actionSheetOptionQuoteMessage": "Citiraj sporočilo", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "discardDraftForOutboxConfirmationDialogMessage": "Ko obnovite neodposlano sporočilo, se vsebina, ki je bila prej v polju za pisanje, zavrže.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." } } diff --git a/assets/l10n/app_zh_Hans_CN.arb b/assets/l10n/app_zh_Hans_CN.arb index db89285899..ed69705ca3 100644 --- a/assets/l10n/app_zh_Hans_CN.arb +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -1178,5 +1178,21 @@ "actionSheetOptionQuoteMessage": "引用消息", "@actionSheetOptionQuoteMessage": { "description": "Label for the 'Quote message' button in the message action sheet." + }, + "upgradeWelcomeDialogTitle": "欢迎来到新的 Zulip 应用!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "开始吧", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "来看看最新的公告吧!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "您将会得到到更快,更流畅的体验。", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." } } diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 32866e7d59..847cf68981 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -21,18 +21,18 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get aboutPageTapToView => 'Tap per visualizzare'; @override - String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + String get upgradeWelcomeDialogTitle => 'Benvenuti alla nuova app Zulip!'; @override String get upgradeWelcomeDialogMessage => - 'You’ll find a familiar experience in a faster, sleeker package.'; + 'Troverai un\'esperienza familiare in un pacchetto più veloce ed elegante.'; @override String get upgradeWelcomeDialogLinkText => - 'Check out the announcement blog post!'; + 'Date un\'occhiata al post dell\'annuncio sul blog!'; @override - String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + String get upgradeWelcomeDialogDismiss => 'Andiamo'; @override String get chooseAccountPageTitle => 'Scegli account'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index ba78c0c8ec..5286c97bdb 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -21,18 +21,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get aboutPageTapToView => 'Нажмите для просмотра'; @override - String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + String get upgradeWelcomeDialogTitle => + 'Добро пожаловать в новое приложение Zulip!'; @override String get upgradeWelcomeDialogMessage => - 'You’ll find a familiar experience in a faster, sleeker package.'; + 'Вы найдете привычные возможности в более быстром и легком приложении.'; @override - String get upgradeWelcomeDialogLinkText => - 'Check out the announcement blog post!'; + String get upgradeWelcomeDialogLinkText => 'Ознакомьтесь с анонсом в блоге!'; @override - String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + String get upgradeWelcomeDialogDismiss => 'Приступим!'; @override String get chooseAccountPageTitle => 'Выберите учетную запись'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 604e924d85..b566059966 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -21,18 +21,17 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get aboutPageTapToView => 'Dotaknite se za ogled'; @override - String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + String get upgradeWelcomeDialogTitle => 'Dobrodošli v novi aplikaciji Zulip!'; @override String get upgradeWelcomeDialogMessage => - 'You’ll find a familiar experience in a faster, sleeker package.'; + 'Čaka vas znana izkušnja v hitrejši in bolj elegantni obliki.'; @override - String get upgradeWelcomeDialogLinkText => - 'Check out the announcement blog post!'; + String get upgradeWelcomeDialogLinkText => 'Preberite objavo na blogu!'; @override - String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + String get upgradeWelcomeDialogDismiss => 'Začnimo'; @override String get chooseAccountPageTitle => 'Izberite račun'; @@ -139,7 +138,7 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get actionSheetOptionShare => 'Deli'; @override - String get actionSheetOptionQuoteMessage => 'Quote message'; + String get actionSheetOptionQuoteMessage => 'Citiraj sporočilo'; @override String get actionSheetOptionStarMessage => 'Označi sporočilo z zvezdico'; @@ -196,14 +195,16 @@ class ZulipLocalizationsSl extends ZulipLocalizations { locale: localeName, other: '$num datotek presega', few: '$num datoteke presegajo', - one: 'Dve datoteki presegata', + two: '$num datoteki presegata', + one: '$num datoteka presega', ); String _temp1 = intl.Intl.pluralLogic( num, locale: localeName, other: 'ne bodo naložene', few: 'ne bodo naložene', - one: 'ne bosta naloženi', + two: 'ne bosta naloženi', + one: 'ne bo naložena', ); return '$_temp0 omejitev velikosti strežnika ($maxFileUploadSizeMib MiB) in $_temp1:\n\n$listMessage'; } @@ -215,7 +216,8 @@ class ZulipLocalizationsSl extends ZulipLocalizations { locale: localeName, other: '$num datotek je prevelikih', few: '$num datoteke so prevelike', - one: 'Dve datoteki sta preveliki', + two: '$num datoteki sta preveliki', + one: '$num datoteka je prevelika', ); return '\"$_temp0\"'; } @@ -358,7 +360,7 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get discardDraftForOutboxConfirmationDialogMessage => - 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + 'Ko obnovite neodposlano sporočilo, se vsebina, ki je bila prej v polju za pisanje, zavrže.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Zavrzi'; @@ -615,7 +617,8 @@ class ZulipLocalizationsSl extends ZulipLocalizations { locale: localeName, other: '$num sporočil', few: '$num sporočila', - one: '2 sporočili', + two: '$num sporočili', + one: '$num sporočilo', ); return 'Označeno je $_temp0 kot prebrano.'; } @@ -633,7 +636,8 @@ class ZulipLocalizationsSl extends ZulipLocalizations { locale: localeName, other: 'Označeno je $num sporočil kot neprebranih', few: 'Označena so $num sporočila kot neprebrana', - one: 'Označeni sta 2 sporočili kot neprebrani', + two: 'Označeni sta $num sporočili kot neprebrani', + one: 'Označeno je $num sporočilo kot neprebrano', ); return '$_temp0.'; } @@ -810,42 +814,44 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get pollWidgetOptionsMissing => 'Ta anketa še nima odgovorov.'; @override - String get initialAnchorSettingTitle => 'Open message feeds at'; + String get initialAnchorSettingTitle => 'Odpri tok sporočil pri'; @override String get initialAnchorSettingDescription => - 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + 'Lahko izberete, ali se tok sporočil odpre pri vašem prvem neprebranem sporočilu ali pri najnovejših sporočilih.'; @override - String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + String get initialAnchorSettingFirstUnreadAlways => + 'Prvo neprebrano sporočilo'; @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'Prvo neprebrano v zasebnih pogovorih, najnovejše drugje'; @override - String get initialAnchorSettingNewestAlways => 'Newest message'; + String get initialAnchorSettingNewestAlways => 'Najnovejše sporočilo'; @override - String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + String get markReadOnScrollSettingTitle => + 'Ob pomikanju označi sporočila kot prebrana'; @override String get markReadOnScrollSettingDescription => - 'When scrolling through messages, should they automatically be marked as read?'; + 'Naj se sporočila ob pomikanju samodejno označijo kot prebrana?'; @override - String get markReadOnScrollSettingAlways => 'Always'; + String get markReadOnScrollSettingAlways => 'Vedno'; @override - String get markReadOnScrollSettingNever => 'Never'; + String get markReadOnScrollSettingNever => 'Nikoli'; @override String get markReadOnScrollSettingConversations => - 'Only in conversation views'; + 'Samo v pogledih pogovorov'; @override String get markReadOnScrollSettingConversationsDescription => - 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + 'Sporočila bodo samodejno označena kot prebrana samo pri ogledu ene teme ali zasebnega pogovora.'; @override String get experimentalFeatureSettingsPageTitle => 'Eksperimentalne funkcije'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index bb02ed6cc2..d787a0808f 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -889,6 +889,18 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { @override String get aboutPageTapToView => '查看更多'; + @override + String get upgradeWelcomeDialogTitle => '欢迎来到新的 Zulip 应用!'; + + @override + String get upgradeWelcomeDialogMessage => '您将会得到到更快,更流畅的体验。'; + + @override + String get upgradeWelcomeDialogLinkText => '来看看最新的公告吧!'; + + @override + String get upgradeWelcomeDialogDismiss => '开始吧'; + @override String get chooseAccountPageTitle => '选择账号'; From 3031f20afe17faa761c5790c46e3909e239f694e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Jun 2025 12:38:44 -0700 Subject: [PATCH 220/290] settings: Reword to "in conversation views" consistently Suggested by Alya: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/last.20code.20changes.20for.20launch/near/2195900 --- 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/generated/l10n/zulip_localizations_uk.dart | 2 +- lib/generated/l10n/zulip_localizations_zh.dart | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 96ce49ffda..a5bb75779a 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -979,7 +979,7 @@ "@initialAnchorSettingFirstUnreadAlways": { "description": "Label for a value of setting controlling initial anchor of message list." }, - "initialAnchorSettingFirstUnreadConversations": "First unread message in single conversations, newest message elsewhere", + "initialAnchorSettingFirstUnreadConversations": "First unread message in conversation views, newest message elsewhere", "@initialAnchorSettingFirstUnreadConversations": { "description": "Label for a value of setting controlling initial anchor of message list." }, diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index cbf3e6841b..241a3bbd16 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1464,7 +1464,7 @@ abstract class ZulipLocalizations { /// Label for a value of setting controlling initial anchor of message list. /// /// In en, this message translates to: - /// **'First unread message in single conversations, newest message elsewhere'** + /// **'First unread message in conversation views, newest message elsewhere'** String get initialAnchorSettingFirstUnreadConversations; /// Label for a value of setting controlling initial anchor of message list. diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 5721604624..e62354d420 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -799,7 +799,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'First unread message in conversation views, newest message elsewhere'; @override String get initialAnchorSettingNewestAlways => 'Newest message'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 0b96cb55eb..0178fe9406 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -799,7 +799,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'First unread message in conversation views, newest message elsewhere'; @override String get initialAnchorSettingNewestAlways => 'Newest message'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 8a5d609fe2..d7c84a08cb 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -799,7 +799,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'First unread message in conversation views, newest message elsewhere'; @override String get initialAnchorSettingNewestAlways => 'Newest message'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 14a250b68a..98bad7d7b8 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -799,7 +799,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'First unread message in conversation views, newest message elsewhere'; @override String get initialAnchorSettingNewestAlways => 'Newest message'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 0477e68eee..0742cfb143 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -801,7 +801,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'First unread message in conversation views, newest message elsewhere'; @override String get initialAnchorSettingNewestAlways => 'Newest message'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 9c406256fa..ca4fc19b35 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -814,7 +814,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'First unread message in conversation views, newest message elsewhere'; @override String get initialAnchorSettingNewestAlways => 'Newest message'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index d787a0808f..ad84f01435 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -799,7 +799,7 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in single conversations, newest message elsewhere'; + 'First unread message in conversation views, newest message elsewhere'; @override String get initialAnchorSettingNewestAlways => 'Newest message'; From b3e6632f8167fe810ef962254787cd6d712bf38e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Jun 2025 13:29:26 -0700 Subject: [PATCH 221/290] version: Sync version and changelog from v30.0.258 release --- docs/changelog.md | 38 ++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 256bcea822..3482cdee3e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,8 +3,46 @@ ## Unreleased +### 30.0.258 (2025-06-16) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users (vs legacy app) + +Welcome to the new Zulip mobile app! You'll find +a familiar experience in a faster, sleeker package. + +For more information or to send us feedback, +see the announcement blog post: +https://blog.zulip.com/flutter-mobile-app-launch + + +### Highlights for users (vs previous beta, v30.0.257) + +* More translation updates. (PR #1596) +* Handle additional error cases in migrating data from + legacy app. (PR #1595) + + +### Highlights for developers + +* User-visible changes not described above: + * Tweak wording of first-unread setting. (PR #1597) + +* Resolved in main: #1070, #1580, PR #1595, PR #1596, PR #1597 + +* Resolved in the experimental branch: + * more toward #46 via PR #1452 + * further toward #46 via PR #1559 + * #296 via PR #1561 + + ## 30.0.257 (2025-06-15) +This was a beta-only release. + This release branch includes some experimental changes not yet merged to the main branch. diff --git a/pubspec.yaml b/pubspec.yaml index 351ef504ea..b4aec916bb 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: 30.0.257+257 +version: 30.0.258+258 environment: # We use a recent version of Flutter from its main channel, and From ca4d0e32bfb5f3de334dcbfb7e4849bbe59f19b8 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Jun 2025 13:30:10 -0700 Subject: [PATCH 222/290] changelog: Fix heading for 30.0.258 --- docs/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 3482cdee3e..eaf91bd31f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,7 +3,7 @@ ## Unreleased -### 30.0.258 (2025-06-16) +## 30.0.258 (2025-06-16) This release branch includes some experimental changes not yet merged to the main branch. From d3d77215508cc0a281de8d2b84292e4bc3394339 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Jun 2025 19:35:28 -0700 Subject: [PATCH 223/290] doc: Update README for launch, hooray --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a8d734d45c..1bc12b65b6 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# Zulip Flutter (beta) +# Zulip Flutter -A Zulip client for Android and iOS, using Flutter. +The official Zulip app for Android and iOS, built with Flutter. -This app is currently [in beta][beta]. -When it's ready, it [will become the new][] official mobile Zulip client. -To see what work is planned before that launch, -see the [milestones][] and the [project board][]. +This app [was launched][] as the main Zulip mobile app +in June 2025. +It replaced the [previous Zulip mobile app][] built with React Native. -[beta]: https://chat.zulip.org/#narrow/stream/2-general/topic/Flutter/near/1708728 -[will become the new]: https://chat.zulip.org/#narrow/stream/2-general/topic/Flutter/near/1582367 -[milestones]: https://github.com/zulip/zulip-flutter/milestones?direction=asc&sort=title -[project board]: https://github.com/orgs/zulip/projects/5/views/4 +[was launched]: https://blog.zulip.com/flutter-mobile-app-launch +[previous Zulip mobile app]: https://github.com/zulip/zulip-mobile#readme -## Using Zulip +## Get the app -To use Zulip on iOS or Android, install the [official mobile Zulip client][]. - -You can also [try out this beta app][beta]. - -[official mobile Zulip client]: https://github.com/zulip/zulip-mobile#readme +Release versions of the app are available here: +* [Zulip for iOS](https://apps.apple.com/app/zulip/id1203036395) + on Apple's App Store +* [Zulip for Android](https://play.google.com/store/apps/details?id=com.zulipmobile) + on the Google Play Store + * Or if you don't use Google Play, you can + [download an APK](https://github.com/zulip/zulip-flutter/releases/latest) + from the official build we post on GitHub. ## Contributing @@ -27,8 +27,8 @@ 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 intend to accept [GSoC 2025 applications][gsoc] -for. +this was among the projects we accepted [GSoC applications][gsoc] for +in 2024 and 2025. [gsoc]: https://zulip.readthedocs.io/en/latest/outreach/gsoc.html#mobile-app From 1ed0d3c75e83f3379e5ae9f21b4ae61a1f19853b Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 17 Jun 2025 14:57:03 +0530 Subject: [PATCH 224/290] notif: Update Pigeon generated Swift code --- ios/Runner/Notifications.g.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift index 40db818d33..82ac8128e4 100644 --- a/ios/Runner/Notifications.g.swift +++ b/ios/Runner/Notifications.g.swift @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// Autogenerated from Pigeon (v25.3.2), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation From 57c2471ee8897c0f9c988b9ebab0dd082fa8d902 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 17 Jun 2025 15:01:35 +0530 Subject: [PATCH 225/290] check: Include ios files as the outputs for the pigeon suite --- tools/check | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/check b/tools/check index 7b02ff70c4..4f839e1450 100755 --- a/tools/check +++ b/tools/check @@ -426,6 +426,7 @@ run_pigeon() { local outputs=( lib/host/'*'.g.dart android/'*'.g.kt + ios/'*'.g.swift ) # Omitted from this check: From 616e77e25b29515dcc481aa5c57a16efc1032bc1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 20 Jun 2025 13:49:53 -0700 Subject: [PATCH 226/290] api: Add realm- and server-level presence settings to InitialSnapshot --- lib/api/model/initial_snapshot.dart | 8 ++++++++ lib/api/model/initial_snapshot.g.dart | 10 ++++++++++ test/example_data.dart | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index cb3df052ac..7fd0863d25 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -34,6 +34,9 @@ class InitialSnapshot { /// * https://zulip.com/api/update-realm-user-settings-defaults#parameter-email_address_visibility final EmailAddressVisibility? emailAddressVisibility; // TODO(server-7): remove + final int serverPresencePingIntervalSeconds; + final int serverPresenceOfflineThresholdSeconds; + // TODO(server-8): Remove the default values. @JsonKey(defaultValue: 15000) final int serverTypingStartedExpiryPeriodMilliseconds; @@ -86,6 +89,8 @@ class InitialSnapshot { final bool realmAllowMessageEditing; final int? realmMessageContentEditLimitSeconds; + final bool realmPresenceDisabled; + final Map realmDefaultExternalAccounts; final int maxFileUploadSizeMib; @@ -131,6 +136,8 @@ class InitialSnapshot { required this.alertWords, required this.customProfileFields, required this.emailAddressVisibility, + required this.serverPresencePingIntervalSeconds, + required this.serverPresenceOfflineThresholdSeconds, required this.serverTypingStartedExpiryPeriodMilliseconds, required this.serverTypingStoppedWaitPeriodMilliseconds, required this.serverTypingStartedWaitPeriodMilliseconds, @@ -148,6 +155,7 @@ class InitialSnapshot { required this.realmWaitingPeriodThreshold, required this.realmAllowMessageEditing, required this.realmMessageContentEditLimitSeconds, + required this.realmPresenceDisabled, required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, required this.serverEmojiDataUrl, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 2cdd365ec5..493e1e8404 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -26,6 +26,10 @@ InitialSnapshot _$InitialSnapshotFromJson( _$EmailAddressVisibilityEnumMap, json['email_address_visibility'], ), + serverPresencePingIntervalSeconds: + (json['server_presence_ping_interval_seconds'] as num).toInt(), + serverPresenceOfflineThresholdSeconds: + (json['server_presence_offline_threshold_seconds'] as num).toInt(), serverTypingStartedExpiryPeriodMilliseconds: (json['server_typing_started_expiry_period_milliseconds'] as num?) ?.toInt() ?? @@ -76,6 +80,7 @@ InitialSnapshot _$InitialSnapshotFromJson( realmAllowMessageEditing: json['realm_allow_message_editing'] as bool, realmMessageContentEditLimitSeconds: (json['realm_message_content_edit_limit_seconds'] as num?)?.toInt(), + realmPresenceDisabled: json['realm_presence_disabled'] as bool, realmDefaultExternalAccounts: (json['realm_default_external_accounts'] as Map).map( (k, e) => MapEntry( @@ -119,6 +124,10 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'custom_profile_fields': instance.customProfileFields, 'email_address_visibility': _$EmailAddressVisibilityEnumMap[instance.emailAddressVisibility], + 'server_presence_ping_interval_seconds': + instance.serverPresencePingIntervalSeconds, + 'server_presence_offline_threshold_seconds': + instance.serverPresenceOfflineThresholdSeconds, 'server_typing_started_expiry_period_milliseconds': instance.serverTypingStartedExpiryPeriodMilliseconds, 'server_typing_stopped_wait_period_milliseconds': @@ -140,6 +149,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'realm_allow_message_editing': instance.realmAllowMessageEditing, 'realm_message_content_edit_limit_seconds': instance.realmMessageContentEditLimitSeconds, + 'realm_presence_disabled': instance.realmPresenceDisabled, 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, 'server_emoji_data_url': instance.serverEmojiDataUrl?.toString(), diff --git a/test/example_data.dart b/test/example_data.dart index 2a2fd2bc1f..72c23c1799 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1100,6 +1100,8 @@ InitialSnapshot initialSnapshot({ List? alertWords, List? customProfileFields, EmailAddressVisibility? emailAddressVisibility, + int? serverPresencePingIntervalSeconds, + int? serverPresenceOfflineThresholdSeconds, int? serverTypingStartedExpiryPeriodMilliseconds, int? serverTypingStoppedWaitPeriodMilliseconds, int? serverTypingStartedWaitPeriodMilliseconds, @@ -1117,6 +1119,7 @@ InitialSnapshot initialSnapshot({ int? realmWaitingPeriodThreshold, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, + bool? realmPresenceDisabled, Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, Uri? serverEmojiDataUrl, @@ -1134,6 +1137,8 @@ InitialSnapshot initialSnapshot({ alertWords: alertWords ?? ['klaxon'], customProfileFields: customProfileFields ?? [], emailAddressVisibility: emailAddressVisibility ?? EmailAddressVisibility.everyone, + serverPresencePingIntervalSeconds: serverPresencePingIntervalSeconds ?? 60, + serverPresenceOfflineThresholdSeconds: serverPresenceOfflineThresholdSeconds ?? 140, serverTypingStartedExpiryPeriodMilliseconds: serverTypingStartedExpiryPeriodMilliseconds ?? 15000, serverTypingStoppedWaitPeriodMilliseconds: @@ -1158,6 +1163,7 @@ InitialSnapshot initialSnapshot({ realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, realmAllowMessageEditing: realmAllowMessageEditing ?? true, realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + realmPresenceDisabled: realmPresenceDisabled ?? false, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, serverEmojiDataUrl: serverEmojiDataUrl From 59457115380fe3bb8184247d0b6332ffca15e1d6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 20 Jun 2025 15:06:01 -0700 Subject: [PATCH 227/290] api: Add `presences` to InitialSnapshot --- lib/api/model/initial_snapshot.dart | 6 ++++++ lib/api/model/initial_snapshot.g.dart | 7 +++++++ lib/api/model/model.dart | 20 ++++++++++++++++++++ lib/api/model/model.g.dart | 12 ++++++++++++ test/example_data.dart | 2 ++ 5 files changed, 47 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 7fd0863d25..a9efdabdd5 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -49,6 +49,11 @@ class InitialSnapshot { final List mutedUsers; + // In the modern format because we pass `slim_presence`. + // TODO(#1611) stop passing and mentioning the deprecated slim_presence; + // presence_last_update_id will be why we get the modern format. + final Map presences; + final Map realmEmoji; final List recentPrivateConversations; @@ -142,6 +147,7 @@ class InitialSnapshot { required this.serverTypingStoppedWaitPeriodMilliseconds, required this.serverTypingStartedWaitPeriodMilliseconds, required this.mutedUsers, + required this.presences, required this.realmEmoji, required this.recentPrivateConversations, required this.savedSnippets, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 493e1e8404..c33c457226 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -45,6 +45,12 @@ InitialSnapshot _$InitialSnapshotFromJson( mutedUsers: (json['muted_users'] as List) .map((e) => MutedUserItem.fromJson(e as Map)) .toList(), + presences: (json['presences'] as Map).map( + (k, e) => MapEntry( + int.parse(k), + PerUserPresence.fromJson(e as Map), + ), + ), realmEmoji: (json['realm_emoji'] as Map).map( (k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map)), ), @@ -135,6 +141,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'server_typing_started_wait_period_milliseconds': instance.serverTypingStartedWaitPeriodMilliseconds, 'muted_users': instance.mutedUsers, + 'presences': instance.presences.map((k, e) => MapEntry(k.toString(), e)), 'realm_emoji': instance.realmEmoji, 'recent_private_conversations': instance.recentPrivateConversations, 'saved_snippets': instance.savedSnippets, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index f284d336da..0feddbd196 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -330,6 +330,26 @@ enum UserRole{ } } +/// A value in [InitialSnapshot.presences]. +/// +/// For docs, search for "presences:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class PerUserPresence { + final int activeTimestamp; + final int idleTimestamp; + + PerUserPresence({ + required this.activeTimestamp, + required this.idleTimestamp, + }); + + factory PerUserPresence.fromJson(Map json) => + _$PerUserPresenceFromJson(json); + + Map toJson() => _$PerUserPresenceToJson(this); +} + /// An item in `saved_snippets` from the initial snapshot. /// /// For docs, search for "saved_snippets:" diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 8c56b4b7fb..833f39bbb3 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -168,6 +168,18 @@ Map _$ProfileFieldUserDataToJson( 'rendered_value': instance.renderedValue, }; +PerUserPresence _$PerUserPresenceFromJson(Map json) => + PerUserPresence( + activeTimestamp: (json['active_timestamp'] as num).toInt(), + idleTimestamp: (json['idle_timestamp'] as num).toInt(), + ); + +Map _$PerUserPresenceToJson(PerUserPresence instance) => + { + 'active_timestamp': instance.activeTimestamp, + 'idle_timestamp': instance.idleTimestamp, + }; + SavedSnippet _$SavedSnippetFromJson(Map json) => SavedSnippet( id: (json['id'] as num).toInt(), title: json['title'] as String, diff --git a/test/example_data.dart b/test/example_data.dart index 72c23c1799..851f3a18e2 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1106,6 +1106,7 @@ InitialSnapshot initialSnapshot({ int? serverTypingStoppedWaitPeriodMilliseconds, int? serverTypingStartedWaitPeriodMilliseconds, List? mutedUsers, + Map? presences, Map? realmEmoji, List? recentPrivateConversations, List? savedSnippets, @@ -1146,6 +1147,7 @@ InitialSnapshot initialSnapshot({ serverTypingStartedWaitPeriodMilliseconds: serverTypingStartedWaitPeriodMilliseconds ?? 10000, mutedUsers: mutedUsers ?? [], + presences: presences ?? {}, realmEmoji: realmEmoji ?? {}, recentPrivateConversations: recentPrivateConversations ?? [], savedSnippets: savedSnippets ?? [], From 66b648aace491421e6cc88c89f37f198f423edd3 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 20 Jun 2025 15:07:34 -0700 Subject: [PATCH 228/290] api: Add `presence` event --- lib/api/model/events.dart | 64 +++++++++++++++++++++++++++++++++++++ lib/api/model/events.g.dart | 41 ++++++++++++++++++++++++ lib/api/model/model.dart | 9 ++++++ lib/api/model/model.g.dart | 5 +++ lib/model/store.dart | 4 +++ 5 files changed, 123 insertions(+) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 2904173e81..6fd21e5c9a 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -74,6 +74,7 @@ sealed class Event { } case 'submessage': return SubmessageEvent.fromJson(json); case 'typing': return TypingEvent.fromJson(json); + case 'presence': return PresenceEvent.fromJson(json); case 'reaction': return ReactionEvent.fromJson(json); case 'heartbeat': return HeartbeatEvent.fromJson(json); // TODO add many more event types @@ -1195,6 +1196,69 @@ enum TypingOp { String toJson() => _$TypingOpEnumMap[this]!; } +/// A Zulip event of type `presence`. +/// +/// See: +/// https://zulip.com/api/get-events#presence +@JsonSerializable(fieldRename: FieldRename.snake) +class PresenceEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'presence'; + + final int userId; + // final String email; // deprecated; ignore + final int serverTimestamp; + final Map presence; + + PresenceEvent({ + required super.id, + required this.userId, + required this.serverTimestamp, + required this.presence, + }); + + factory PresenceEvent.fromJson(Map json) => + _$PresenceEventFromJson(json); + + @override + Map toJson() => _$PresenceEventToJson(this); +} + +/// A value in [PresenceEvent.presence]. +/// +/// The "per client" name follows the event's structure, +/// but that structure is already an API wart; see the doc's "Changes" note +/// on [client] and on the `client_name` key of the map that holds these values: +/// +/// https://zulip.com/api/get-events#presence +/// > Starting with Zulip 7.0 (feature level 178), this will always be "website" +/// > as the server no longer stores which client submitted presence updates. +/// +/// This will probably be deprecated in favor of a form like [PerUserPresence]. +/// See #1611 and discussion: +/// https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.20rewrite/near/2200812 +// TODO(#1611) update comment about #1611 +@JsonSerializable(fieldRename: FieldRename.snake) +class PerClientPresence { + final String client; // always "website" (on 7.0+, so on all supported servers) + final PresenceStatus status; + final int timestamp; + final bool pushable; // always false (on 7.0+, so on all supported servers) + + PerClientPresence({ + required this.client, + required this.status, + required this.timestamp, + required this.pushable, + }); + + factory PerClientPresence.fromJson(Map json) => + _$PerClientPresenceFromJson(json); + + Map toJson() => _$PerClientPresenceToJson(this); +} + /// A Zulip event of type `reaction`, with op `add` or `remove`. /// /// See: diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index bb8119e8ed..2203b2d9df 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -716,6 +716,47 @@ Map _$TypingEventToJson(TypingEvent instance) => const _$TypingOpEnumMap = {TypingOp.start: 'start', TypingOp.stop: 'stop'}; +PresenceEvent _$PresenceEventFromJson(Map json) => + PresenceEvent( + id: (json['id'] as num).toInt(), + userId: (json['user_id'] as num).toInt(), + serverTimestamp: (json['server_timestamp'] as num).toInt(), + presence: (json['presence'] as Map).map( + (k, e) => + MapEntry(k, PerClientPresence.fromJson(e as Map)), + ), + ); + +Map _$PresenceEventToJson(PresenceEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'user_id': instance.userId, + 'server_timestamp': instance.serverTimestamp, + 'presence': instance.presence, + }; + +PerClientPresence _$PerClientPresenceFromJson(Map json) => + PerClientPresence( + client: json['client'] as String, + status: $enumDecode(_$PresenceStatusEnumMap, json['status']), + timestamp: (json['timestamp'] as num).toInt(), + pushable: json['pushable'] as bool, + ); + +Map _$PerClientPresenceToJson(PerClientPresence instance) => + { + 'client': instance.client, + 'status': instance.status, + 'timestamp': instance.timestamp, + 'pushable': instance.pushable, + }; + +const _$PresenceStatusEnumMap = { + PresenceStatus.active: 'active', + PresenceStatus.idle: 'idle', +}; + ReactionEvent _$ReactionEventFromJson(Map json) => ReactionEvent( id: (json['id'] as num).toInt(), diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 0feddbd196..619d57e3c4 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -350,6 +350,15 @@ class PerUserPresence { Map toJson() => _$PerUserPresenceToJson(this); } +/// As in [PerClientPresence.status]. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum PresenceStatus { + active, + idle; + + String toJson() => _$PresenceStatusEnumMap[this]!; +} + /// An item in `saved_snippets` from the initial snapshot. /// /// For docs, search for "saved_snippets:" diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 833f39bbb3..eff1019cda 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -422,6 +422,11 @@ const _$EmojisetEnumMap = { Emojiset.text: 'text', }; +const _$PresenceStatusEnumMap = { + PresenceStatus.active: 'active', + PresenceStatus.idle: 'idle', +}; + const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.name: 'name', ChannelPropertyName.description: 'description', diff --git a/lib/model/store.dart b/lib/model/store.dart index 440634d76b..a0cd12c33e 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -952,6 +952,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor assert(debugLog("server event: typing/${event.op} ${event.messageType}")); typingStatus.handleTypingEvent(event); + case PresenceEvent(): + // TODO handle + break; + case ReactionEvent(): assert(debugLog("server event: reaction/${event.op}")); _messages.handleReactionEvent(event); From a023bc726194a602955167ec9f428177c0f924d8 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 20 Jun 2025 15:37:12 -0700 Subject: [PATCH 229/290] api: Add updatePresence --- lib/api/model/model.dart | 2 +- lib/api/route/users.dart | 47 ++++++++++++++++++++++++++++++++++ lib/api/route/users.g.dart | 21 +++++++++++++++ test/api/route/users_test.dart | 39 ++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 test/api/route/users_test.dart diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 619d57e3c4..46e65dccac 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -350,7 +350,7 @@ class PerUserPresence { Map toJson() => _$PerUserPresenceToJson(this); } -/// As in [PerClientPresence.status]. +/// As in [PerClientPresence.status] and [updatePresence]. @JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) enum PresenceStatus { active, diff --git a/lib/api/route/users.dart b/lib/api/route/users.dart index 012f14e6b9..d07c471e2f 100644 --- a/lib/api/route/users.dart +++ b/lib/api/route/users.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../core.dart'; +import '../model/model.dart'; part 'users.g.dart'; @@ -32,3 +33,49 @@ class GetOwnUserResult { Map toJson() => _$GetOwnUserResultToJson(this); } + +/// https://zulip.com/api/update-presence +/// +/// Passes true for `slim_presence` to avoid getting an ancient data format +/// in the response. +// TODO(#1611) Passing `slim_presence` is the old, deprecated way to avoid +// getting an ancient data format. Pass `last_update_id` to new servers to get +// that effect (make lastUpdateId required?) and update the dartdoc. +// (Passing `slim_presence`, for now, shouldn't break things, but we'd like to +// stop; see discussion: +// https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.20rewrite/near/2201035 ) +Future updatePresence(ApiConnection connection, { + int? lastUpdateId, + int? historyLimitDays, + bool? newUserInput, + bool? pingOnly, + required PresenceStatus status, +}) { + return connection.post('updatePresence', UpdatePresenceResult.fromJson, 'users/me/presence', { + if (lastUpdateId != null) 'last_update_id': lastUpdateId, + if (historyLimitDays != null) 'history_limit_days': historyLimitDays, + if (newUserInput != null) 'new_user_input': newUserInput, + if (pingOnly != null) 'ping_only': pingOnly, + 'status': RawParameter(status.toJson()), + 'slim_presence': true, + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdatePresenceResult { + final int? presenceLastUpdateId; // TODO(server-9.0) new in FL 263 + final double? serverTimestamp; // 1656958539.6287155 in the example response + final Map? presences; + // final bool zephyrMirrorActive; // deprecated, ignore + + UpdatePresenceResult({ + required this.presenceLastUpdateId, + required this.serverTimestamp, + required this.presences, + }); + + factory UpdatePresenceResult.fromJson(Map json) => + _$UpdatePresenceResultFromJson(json); + + Map toJson() => _$UpdatePresenceResultToJson(this); +} diff --git a/lib/api/route/users.g.dart b/lib/api/route/users.g.dart index e03ccfc041..dab0e32189 100644 --- a/lib/api/route/users.g.dart +++ b/lib/api/route/users.g.dart @@ -13,3 +13,24 @@ GetOwnUserResult _$GetOwnUserResultFromJson(Map json) => Map _$GetOwnUserResultToJson(GetOwnUserResult instance) => {'user_id': instance.userId}; + +UpdatePresenceResult _$UpdatePresenceResultFromJson( + Map json, +) => UpdatePresenceResult( + presenceLastUpdateId: (json['presence_last_update_id'] as num?)?.toInt(), + serverTimestamp: (json['server_timestamp'] as num?)?.toDouble(), + presences: (json['presences'] as Map?)?.map( + (k, e) => MapEntry( + int.parse(k), + PerUserPresence.fromJson(e as Map), + ), + ), +); + +Map _$UpdatePresenceResultToJson( + UpdatePresenceResult instance, +) => { + 'presence_last_update_id': instance.presenceLastUpdateId, + 'server_timestamp': instance.serverTimestamp, + 'presences': instance.presences?.map((k, e) => MapEntry(k.toString(), e)), +}; diff --git a/test/api/route/users_test.dart b/test/api/route/users_test.dart new file mode 100644 index 0000000000..b83c801a2a --- /dev/null +++ b/test/api/route/users_test.dart @@ -0,0 +1,39 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/users.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; + +void main() { + test('smoke updatePresence', () { + return FakeApiConnection.with_((connection) async { + final response = UpdatePresenceResult( + presenceLastUpdateId: -1, + serverTimestamp: 1656958539.6287155, + presences: {}, + ); + connection.prepare(json: response.toJson()); + await updatePresence(connection, + lastUpdateId: -1, + historyLimitDays: 21, + newUserInput: false, + pingOnly: false, + status: PresenceStatus.active, + ); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/presence') + ..bodyFields.deepEquals({ + 'last_update_id': '-1', + 'history_limit_days': '21', + 'new_user_input': 'false', + 'ping_only': 'false', + 'status': 'active', + 'slim_presence': 'true', + }); + }); + }); +} From 3284ea5038d7be6897fa097633b4038ba5ee7b4e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 20 Jun 2025 16:36:08 -0700 Subject: [PATCH 230/290] store: Add realm- and server-level presence settings to PerAccountStore --- lib/model/store.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/model/store.dart b/lib/model/store.dart index a0cd12c33e..725403dd92 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -474,9 +474,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot); return PerAccountStore._( core: core, + serverPresencePingIntervalSeconds: initialSnapshot.serverPresencePingIntervalSeconds, + serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds, realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy, realmMandatoryTopics: initialSnapshot.realmMandatoryTopics, realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold, + realmPresenceDisabled: initialSnapshot.realmPresenceDisabled, maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib, realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName, realmAllowMessageEditing: initialSnapshot.realmAllowMessageEditing, @@ -516,9 +519,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor PerAccountStore._({ required super.core, + required this.serverPresencePingIntervalSeconds, + required this.serverPresenceOfflineThresholdSeconds, required this.realmWildcardMentionPolicy, required this.realmMandatoryTopics, required this.realmWaitingPeriodThreshold, + required this.realmPresenceDisabled, required this.maxFileUploadSizeMib, required String? realmEmptyTopicDisplayName, required this.realmAllowMessageEditing, @@ -570,12 +576,16 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor //////////////////////////////// // Data attached to the realm or the server. + final int serverPresencePingIntervalSeconds; + final int serverPresenceOfflineThresholdSeconds; + final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting final bool realmMandatoryTopics; // TODO(#668): update this realm setting /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting final bool realmAllowMessageEditing; // TODO(#668): update this realm setting final int? realmMessageContentEditLimitSeconds; // TODO(#668): update this realm setting + final bool realmPresenceDisabled; // TODO(#668): update this realm setting final int maxFileUploadSizeMib; // No event for this. /// The display name to use for empty topics. From 5d43df2bee1c512b907a83999b07ec5340bf8318 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 23 Jun 2025 16:37:15 -0600 Subject: [PATCH 231/290] store: Add `Presence` model, storing and reporting presence We plan to write tests for this as a followup: #1620. Notable differences from zulip-mobile: - Here, we make report-presence requests more frequently: our "app state" listener triggers a request immediately, instead of scheduling it when the "ping interval" expires. This approach anticipates the requests being handled much more efficiently, with presence_last_update_id (#1611) -- but it shouldn't regress on performance now, because these immediate requests are done (for now) as "ping only", i.e., asking the server not to compute a presence data payload. - The newUserInput param is now usually true instead of always false. This seems more correct to me, and the change seems low-stakes (the doc says it's used to implement usage statistics); see the doc: https://zulip.com/api/update-presence#parameter-new_user_input Fixes: #196 --- lib/model/presence.dart | 182 +++++++++++++++++++++++++++++++++++++ lib/model/store.dart | 13 ++- test/model/store_test.dart | 2 + 3 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 lib/model/presence.dart diff --git a/lib/model/presence.dart b/lib/model/presence.dart new file mode 100644 index 0000000000..d21ece421a --- /dev/null +++ b/lib/model/presence.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; +import '../api/route/users.dart'; +import 'store.dart'; + +/// The model for tracking which users are online, idle, and offline. +/// +/// Use [presenceStatusForUser]. If that returns null, the user is offline. +/// +/// This substore is its own [ChangeNotifier], +/// so callers need to remember to add a listener (and remove it on dispose). +/// In particular, [PerAccountStoreWidget] doesn't subscribe a widget subtree +/// to updates. +class Presence extends PerAccountStoreBase with ChangeNotifier { + Presence({ + required super.core, + required this.serverPresencePingInterval, + required this.serverPresenceOfflineThresholdSeconds, + required this.realmPresenceDisabled, + required Map initial, + }) : _map = initial; + + final Duration serverPresencePingInterval; + final int serverPresenceOfflineThresholdSeconds; + // TODO(#668): update this realm setting (probably by accessing it from a new + // realm/server-settings substore that gets passed to Presence) + final bool realmPresenceDisabled; + + Map _map; + + AppLifecycleListener? _appLifecycleListener; + + void _handleLifecycleStateChange(AppLifecycleState newState) { + assert(!_disposed); // We remove the listener in [dispose]. + + // Since this handler can cause multiple requests within a + // serverPresencePingInterval period, we pass `pingOnly: true`, for now, because: + // - This makes the request cheap for the server. + // - We don't want to record stale presence data when responses arrive out + // of order. This handler would increase the risk of that by potentially + // sending requests more frequently than serverPresencePingInterval. + // (`pingOnly: true` causes presence data to be omitted in the response.) + // TODO(#1611) Both of these reasons can be easily addressed by passing + // lastUpdateId. Do that, and stop sending `pingOnly: true`. + // (For the latter point, we'd ignore responses with a stale lastUpdateId.) + _maybePingAndRecordResponse(newState, pingOnly: true); + } + + bool _hasStarted = false; + + void start() async { + if (!debugEnable) return; + if (_hasStarted) { + throw StateError('Presence.start should only be called once.'); + } + _hasStarted = true; + + _appLifecycleListener = AppLifecycleListener( + onStateChange: _handleLifecycleStateChange); + + _poll(); + } + + Future _maybePingAndRecordResponse(AppLifecycleState? appLifecycleState, { + required bool pingOnly, + }) async { + if (realmPresenceDisabled) return; + + final UpdatePresenceResult result; + switch (appLifecycleState) { + case null: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + // No presence update. + return; + case AppLifecycleState.detached: + // > The application is still hosted by a Flutter engine but is + // > detached from any host views. + // TODO see if this actually works as a way to send an "idle" update + // when the user closes the app completely. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.idle, + newUserInput: false); + case AppLifecycleState.resumed: + // > […] the default running mode for a running application that has + // > input focus and is visible. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.active, + newUserInput: true); + case AppLifecycleState.inactive: + // > At least one view of the application is visible, but none have + // > input focus. The application is otherwise running normally. + // For example, we expect this state when the user is selecting a file + // to upload. + result = await updatePresence(connection, + pingOnly: pingOnly, + status: PresenceStatus.active, + newUserInput: false); + } + if (!pingOnly) { + _map = result.presences!; + notifyListeners(); + } + } + + void _poll() async { + assert(!_disposed); + while (true) { + // We put the wait upfront because we already have data when [start] is + // called; it comes from /register. + await Future.delayed(serverPresencePingInterval); + if (_disposed) return; + + await _maybePingAndRecordResponse( + SchedulerBinding.instance.lifecycleState, pingOnly: false); + if (_disposed) return; + } + } + + bool _disposed = false; + + @override + void dispose() { + _appLifecycleListener?.dispose(); + _disposed = true; + super.dispose(); + } + + /// The [PresenceStatus] for [userId], or null if the user is offline. + PresenceStatus? presenceStatusForUser(int userId, {required DateTime utcNow}) { + final now = utcNow.millisecondsSinceEpoch ~/ 1000; + final perUserPresence = _map[userId]; + if (perUserPresence == null) return null; + final PerUserPresence(:activeTimestamp, :idleTimestamp) = perUserPresence; + + if (now - activeTimestamp <= serverPresenceOfflineThresholdSeconds) { + return PresenceStatus.active; + } else if (now - idleTimestamp <= serverPresenceOfflineThresholdSeconds) { + // The API doc is kind of confusing, but this seems correct: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.3A.20.22potentially.20present.22.3F/near/2202431 + // TODO clarify that API doc + return PresenceStatus.idle; + } else { + return null; + } + } + + void handlePresenceEvent(PresenceEvent event) { + // TODO(#1618) + } + + /// In debug mode, controls whether presence requests are made. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugEnable { + bool result = true; + assert(() { + result = _debugEnable; + return true; + }()); + return result; + } + static bool _debugEnable = true; + static set debugEnable(bool value) { + assert(() { + _debugEnable = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + debugEnable = true; + } +} diff --git a/lib/model/store.dart b/lib/model/store.dart index 725403dd92..7551c8be85 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -26,6 +26,7 @@ import 'emoji.dart'; import 'localizations.dart'; import 'message.dart'; import 'message_list.dart'; +import 'presence.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; import 'channel.dart'; @@ -501,8 +502,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor ), users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot), typingStatus: TypingStatus(core: core, - typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds), - ), + typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds)), + presence: Presence(core: core, + serverPresencePingInterval: Duration(seconds: initialSnapshot.serverPresencePingIntervalSeconds), + serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds, + realmPresenceDisabled: initialSnapshot.realmPresenceDisabled, + initial: initialSnapshot.presences), channels: channels, messages: MessageStoreImpl(core: core, realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName), @@ -538,6 +543,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.typingNotifier, required UserStoreImpl users, required this.typingStatus, + required this.presence, required ChannelStoreImpl channels, required MessageStoreImpl messages, required this.unreads, @@ -663,6 +669,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor final TypingStatus typingStatus; + final Presence presence; + /// Whether [user] has passed the realm's waiting period to be a full member. /// /// See: @@ -1228,6 +1236,7 @@ class UpdateMachine { // TODO do registerNotificationToken before registerQueue: // https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807 unawaited(updateMachine.registerNotificationToken()); + store.presence.start(); return updateMachine; } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index c3aadb1171..68f9503fce 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -17,6 +17,7 @@ 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/presence.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/receive.dart'; @@ -31,6 +32,7 @@ import 'test_store.dart'; void main() { TestZulipBinding.ensureInitialized(); + Presence.debugEnable = false; final account1 = eg.selfAccount.copyWith(id: 1); final account2 = eg.otherAccount.copyWith(id: 2); From f11c52f56b33fccbb61ad2a17dfd62641fe024bb Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 20 Jun 2025 16:25:39 -0700 Subject: [PATCH 232/290] presence: Show presence on avatars throughout the app, and in profile We plan to write tests for this as a followup: #1620. Fixes: #1607 --- lib/widgets/content.dart | 156 ++++++++++++++++++++++- lib/widgets/home.dart | 6 +- lib/widgets/message_list.dart | 5 +- lib/widgets/profile.dart | 24 +++- lib/widgets/recent_dm_conversations.dart | 13 +- lib/widgets/theme.dart | 29 +++++ 6 files changed, 224 insertions(+), 9 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 44411434fd..eee41d785e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -12,9 +12,11 @@ import '../api/core.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/avatar_url.dart'; +import '../model/binding.dart'; import '../model/content.dart'; import '../model/internal_link.dart'; import '../model/katex.dart'; +import '../model/presence.dart'; import 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; @@ -1662,17 +1664,26 @@ class Avatar extends StatelessWidget { required this.userId, required this.size, required this.borderRadius, + this.backgroundColor, + this.showPresence = true, }); final int userId; final double size; final double borderRadius; + final Color? backgroundColor; + final bool showPresence; @override Widget build(BuildContext context) { + // (The backgroundColor is only meaningful if presence will be shown; + // see [PresenceCircle.backgroundColor].) + assert(backgroundColor == null || showPresence); return AvatarShape( size: size, borderRadius: borderRadius, + backgroundColor: backgroundColor, + userIdForPresence: showPresence ? userId : null, child: AvatarImage(userId: userId, size: size)); } } @@ -1722,26 +1733,169 @@ class AvatarImage extends StatelessWidget { } /// A rounded square shape, to wrap an [AvatarImage] or similar. +/// +/// If [userIdForPresence] is provided, this will paint a [PresenceCircle] +/// on the shape. class AvatarShape extends StatelessWidget { const AvatarShape({ super.key, required this.size, required this.borderRadius, + this.backgroundColor, + this.userIdForPresence, required this.child, }); final double size; final double borderRadius; + final Color? backgroundColor; + final int? userIdForPresence; final Widget child; @override Widget build(BuildContext context) { - return SizedBox.square( + // (The backgroundColor is only meaningful if presence will be shown; + // see [PresenceCircle.backgroundColor].) + assert(backgroundColor == null || userIdForPresence != null); + + Widget result = SizedBox.square( dimension: size, child: ClipRRect( borderRadius: BorderRadius.all(Radius.circular(borderRadius)), clipBehavior: Clip.antiAlias, child: child)); + + if (userIdForPresence != null) { + final presenceCircleSize = size / 4; // TODO(design) is this right? + result = Stack(children: [ + result, + Positioned.directional(textDirection: Directionality.of(context), + end: 0, + bottom: 0, + child: PresenceCircle( + userId: userIdForPresence!, + size: presenceCircleSize, + backgroundColor: backgroundColor)), + ]); + } + + return result; + } +} + +/// The green or orange-gradient circle representing [PresenceStatus]. +/// +/// [backgroundColor] must not be [Colors.transparent]. +/// It exists to match the background on which the avatar image is painted. +/// If [backgroundColor] is not passed, [DesignVariables.mainBackground] is used. +/// +/// By default, nothing paints for a user in the "offline" status +/// (i.e. a user without a [PresenceStatus]). +/// Pass true for [explicitOffline] to paint a gray circle. +class PresenceCircle extends StatefulWidget { + const PresenceCircle({ + super.key, + required this.userId, + required this.size, + this.backgroundColor, + this.explicitOffline = false, + }); + + final int userId; + final double size; + final Color? backgroundColor; + final bool explicitOffline; + + /// Creates a [WidgetSpan] with a [PresenceCircle], for use in rich text + /// before a user's name. + /// + /// The [PresenceCircle] will have `explicitOffline: true`. + static InlineSpan asWidgetSpan({ + required int userId, + required double fontSize, + required TextScaler textScaler, + Color? backgroundColor, + }) { + final size = textScaler.scale(fontSize) / 2; + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: PresenceCircle( + userId: userId, + size: size, + backgroundColor: backgroundColor, + explicitOffline: true))); + } + + @override + State createState() => _PresenceCircleState(); +} + +class _PresenceCircleState extends State with PerAccountStoreAwareStateMixin { + Presence? model; + + @override + void onNewStore() { + model?.removeListener(_modelChanged); + model = PerAccountStoreWidget.of(context).presence + ..addListener(_modelChanged); + } + + @override + void dispose() { + model!.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in [model]. + // This method was called because that just changed. + }); + } + + @override + Widget build(BuildContext context) { + final status = model!.presenceStatusForUser( + widget.userId, utcNow: ZulipBinding.instance.utcNow()); + final designVariables = DesignVariables.of(context); + final effectiveBackgroundColor = widget.backgroundColor ?? designVariables.mainBackground; + assert(effectiveBackgroundColor != Colors.transparent); + + Color? color; + LinearGradient? gradient; + switch (status) { + case null: + if (widget.explicitOffline) { + // TODO(a11y) this should be an open circle, like on web, + // to differentiate by shape (vs. the "active" status which is also + // a solid circle) + color = designVariables.statusAway; + } else { + return SizedBox.square(dimension: widget.size); + } + case PresenceStatus.active: + color = designVariables.statusOnline; + case PresenceStatus.idle: + gradient = LinearGradient( + begin: AlignmentDirectional.centerStart, + end: AlignmentDirectional.centerEnd, + colors: [designVariables.statusIdle, effectiveBackgroundColor], + stops: [0.05, 1.00], + ); + } + + return SizedBox.square(dimension: widget.size, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: effectiveBackgroundColor, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside), + color: color, + gradient: gradient, + shape: BoxShape.circle))); } } diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 4e70bb1e76..9a0850e0b9 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -570,7 +570,11 @@ class _MyProfileButton extends _MenuButton { Widget buildLeading(BuildContext context) { final store = PerAccountStoreWidget.of(context); return Avatar( - userId: store.selfUserId, size: _MenuButton._iconSize, borderRadius: 4); + userId: store.selfUserId, + size: _MenuButton._iconSize, + borderRadius: 4, + showPresence: false, + ); } @override diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 75c8b5cee0..39ab1b4f04 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1704,7 +1704,10 @@ class _SenderRow extends StatelessWidget { userId: message.senderId)), child: Row( children: [ - Avatar(size: 32, borderRadius: 3, + Avatar( + size: 32, + borderRadius: 3, + showPresence: false, userId: message.senderId), const SizedBox(width: 8), Flexible( diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index f1328b3367..6c8e8b0b5e 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -44,15 +44,31 @@ class ProfilePage extends StatelessWidget { return const _ProfileErrorPage(); } + final nameStyle = _TextStyles.primaryFieldText + .merge(weightVariableTextStyle(context, wght: 700)); + final displayEmail = store.userDisplayEmail(user); final items = [ Center( - child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)), + child: Avatar( + userId: userId, + size: 200, + borderRadius: 200 / 8, + // Would look odd with this large image; + // we'll show it by the user's name instead. + showPresence: false)), const SizedBox(height: 16), - Text(user.fullName, + Text.rich( + TextSpan(children: [ + PresenceCircle.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + ), + TextSpan(text: user.fullName), + ]), textAlign: TextAlign.center, - style: _TextStyles.primaryFieldText - .merge(weightVariableTextStyle(context, wght: 700))), + style: nameStyle), if (displayEmail != null) Text(displayEmail, textAlign: TextAlign.center, diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index d392998268..28a0561f0d 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -101,6 +101,7 @@ class RecentDmConversationsItem extends StatelessWidget { final String title; final Widget avatar; + int? userIdForPresence; switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage] case []: title = store.selfUser.fullName; @@ -111,6 +112,7 @@ class RecentDmConversationsItem extends StatelessWidget { // 1:1 DM conversations from muted users?) title = store.userDisplayName(otherUserId); avatar = AvatarImage(userId: otherUserId, size: _avatarSize); + userIdForPresence = otherUserId; default: // TODO(i18n): List formatting, like you can do in JavaScript: // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) @@ -123,8 +125,10 @@ class RecentDmConversationsItem extends StatelessWidget { ZulipIcons.group_dm))); } + // TODO(design) check if this is the right variable + final backgroundColor = designVariables.background; return Material( - color: designVariables.background, // TODO(design) check if this is the right variable + color: backgroundColor, child: InkWell( onTap: () { Navigator.push(context, @@ -133,7 +137,12 @@ class RecentDmConversationsItem extends StatelessWidget { child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding(padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 0, 8), - child: AvatarShape(size: _avatarSize, borderRadius: 3, child: avatar)), + child: AvatarShape( + size: _avatarSize, + borderRadius: 3, + backgroundColor: userIdForPresence != null ? backgroundColor : null, + userIdForPresence: userIdForPresence, + child: avatar)), const SizedBox(width: 8), Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 72a592f004..b837780d3f 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -173,6 +173,13 @@ class DesignVariables extends ThemeExtension { mainBackground: const Color(0xfff0f0f0), radioBorder: Color(0xffbbbdc8), radioFillSelected: Color(0xff4370f0), + statusAway: Color(0xff73788c).withValues(alpha: 0.25), + + // Following Web because it uses a gradient, to distinguish it by shape from + // the "active" dot, and the Figma doesn't; Figma just has solid #d5bb6c. + statusIdle: Color(0xfff5b266), + + statusOnline: Color(0xff46aa62), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), bgSearchInput: const Color(0xffe3e3e3), @@ -242,6 +249,13 @@ class DesignVariables extends ThemeExtension { mainBackground: const Color(0xff1d1d1d), radioBorder: Color(0xff626573), radioFillSelected: Color(0xff4e7cfa), + statusAway: Color(0xffabaeba).withValues(alpha: 0.30), + + // Following Web because it uses a gradient, to distinguish it by shape from + // the "active" dot, and the Figma doesn't; Figma just has solid #8c853b. + statusIdle: Color(0xffae640a), + + statusOnline: Color(0xff44bb66), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff).withValues(alpha: 0.9), bgSearchInput: const Color(0xff313131), @@ -319,6 +333,9 @@ class DesignVariables extends ThemeExtension { required this.mainBackground, required this.radioBorder, required this.radioFillSelected, + required this.statusAway, + required this.statusIdle, + required this.statusOnline, required this.textInput, required this.title, required this.bgSearchInput, @@ -397,6 +414,9 @@ class DesignVariables extends ThemeExtension { final Color mainBackground; final Color radioBorder; final Color radioFillSelected; + final Color statusAway; + final Color statusIdle; + final Color statusOnline; final Color textInput; final Color title; final Color bgSearchInput; @@ -470,6 +490,9 @@ class DesignVariables extends ThemeExtension { Color? mainBackground, Color? radioBorder, Color? radioFillSelected, + Color? statusAway, + Color? statusIdle, + Color? statusOnline, Color? textInput, Color? title, Color? bgSearchInput, @@ -538,6 +561,9 @@ class DesignVariables extends ThemeExtension { mainBackground: mainBackground ?? this.mainBackground, radioBorder: radioBorder ?? this.radioBorder, radioFillSelected: radioFillSelected ?? this.radioFillSelected, + statusAway: statusAway ?? this.statusAway, + statusIdle: statusIdle ?? this.statusIdle, + statusOnline: statusOnline ?? this.statusOnline, textInput: textInput ?? this.textInput, title: title ?? this.title, bgSearchInput: bgSearchInput ?? this.bgSearchInput, @@ -613,6 +639,9 @@ class DesignVariables extends ThemeExtension { mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, radioBorder: Color.lerp(radioBorder, other.radioBorder, t)!, radioFillSelected: Color.lerp(radioFillSelected, other.radioFillSelected, t)!, + statusAway: Color.lerp(statusAway, other.statusAway, t)!, + statusIdle: Color.lerp(statusIdle, other.statusIdle, t)!, + statusOnline: Color.lerp(statusOnline, other.statusOnline, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, From edba020d6acf57c2908444c8d35dee7c285cc36c Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Mon, 23 Jun 2025 19:02:06 +0200 Subject: [PATCH 233/290] l10n: Update translations from Weblate. --- assets/l10n/app_de.arb | 18 +- assets/l10n/app_pl.arb | 18 +- assets/l10n/app_ru.arb | 2 +- assets/l10n/app_sl.arb | 2 +- assets/l10n/app_uk.arb | 68 +++ assets/l10n/app_zh_Hans_CN.arb | 2 +- assets/l10n/app_zh_Hant_TW.arb | 410 +++++++++++++++++- .../l10n/zulip_localizations_de.dart | 10 +- .../l10n/zulip_localizations_pl.dart | 10 +- .../l10n/zulip_localizations_ru.dart | 2 +- .../l10n/zulip_localizations_sl.dart | 2 +- .../l10n/zulip_localizations_uk.dart | 38 +- .../l10n/zulip_localizations_zh.dart | 301 ++++++++++++- 13 files changed, 821 insertions(+), 62 deletions(-) diff --git a/assets/l10n/app_de.arb b/assets/l10n/app_de.arb index c7731cd293..7ea04c0865 100644 --- a/assets/l10n/app_de.arb +++ b/assets/l10n/app_de.arb @@ -155,7 +155,7 @@ "@initialAnchorSettingFirstUnreadAlways": { "description": "Label for a value of setting controlling initial anchor of message list." }, - "initialAnchorSettingFirstUnreadConversations": "Erste ungelesene Nachricht in Einzelunterhaltungen, sonst neueste Nachricht", + "initialAnchorSettingFirstUnreadConversations": "Erste ungelesene Nachricht in Unterhaltungsansicht, sonst neueste Nachricht", "@initialAnchorSettingFirstUnreadConversations": { "description": "Label for a value of setting controlling initial anchor of message list." }, @@ -1180,5 +1180,21 @@ "mutedSender": "Stummgeschalteter Absender", "@mutedSender": { "description": "Name for a muted user to display in message list." + }, + "upgradeWelcomeDialogTitle": "Willkommen bei der neuen Zulip-App!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "Du wirst ein vertrautes Erlebnis in einer schnelleren, schlankeren App erleben.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Sieh dir den Ankündigungs-Blogpost an!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Los gehts", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." } } diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 168ede020a..1d919c5a9d 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1145,7 +1145,7 @@ "@discardDraftForOutboxConfirmationDialogMessage": { "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." }, - "initialAnchorSettingFirstUnreadConversations": "Pierwsza nieprzeczytana wiadomość w pojedynczej dyskusji, wszędzie indziej najnowsza wiadomość", + "initialAnchorSettingFirstUnreadConversations": "Pierwsza nieprzeczytana wiadomość w widoku dyskusji, wszędzie indziej najnowsza wiadomość", "@initialAnchorSettingFirstUnreadConversations": { "description": "Label for a value of setting controlling initial anchor of message list." }, @@ -1180,5 +1180,21 @@ "markReadOnScrollSettingConversationsDescription": "Wiadomości zostaną z automatu oznaczone jako przeczytane tylko w pojedyczym wątku lub w wymianie wiadomości bezpośrednich.", "@markReadOnScrollSettingConversationsDescription": { "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "upgradeWelcomeDialogTitle": "Witaj w nowej apce Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogMessage": "Napotkasz na znane rozwiązania, które upakowaliśmy w szybszy i elegancki pakiet.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Sprawdź blog pod kątem obwieszczenia!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Zaczynajmy", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index cf53185e2c..acff65ee7f 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1149,7 +1149,7 @@ "@initialAnchorSettingDescription": { "description": "Description of setting controlling initial anchor of message list." }, - "initialAnchorSettingFirstUnreadConversations": "Первое непрочитанное сообщение в личных беседах, самое новое в остальных", + "initialAnchorSettingFirstUnreadConversations": "Первое непрочитанное сообщение при просмотре бесед, самое новое в остальных местах", "@initialAnchorSettingFirstUnreadConversations": { "description": "Label for a value of setting controlling initial anchor of message list." }, diff --git a/assets/l10n/app_sl.arb b/assets/l10n/app_sl.arb index e0f0ae9fc1..a2f93c117a 100644 --- a/assets/l10n/app_sl.arb +++ b/assets/l10n/app_sl.arb @@ -1161,7 +1161,7 @@ "@markReadOnScrollSettingConversations": { "description": "Label for a value of setting controlling which message-list views should mark read on scroll." }, - "initialAnchorSettingFirstUnreadConversations": "Prvo neprebrano v zasebnih pogovorih, najnovejše drugje", + "initialAnchorSettingFirstUnreadConversations": "Prvo neprebrano v pogovorih, najnovejše drugje", "@initialAnchorSettingFirstUnreadConversations": { "description": "Label for a value of setting controlling initial anchor of message list." }, diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb index 0f7291df60..a0fd63348b 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -1128,5 +1128,73 @@ "mutedUser": "Заглушений користувач", "@mutedUser": { "description": "Name for a muted user to display all over the app." + }, + "initialAnchorSettingFirstUnreadAlways": "Перше непрочитане повідомлення", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "upgradeWelcomeDialogTitle": "Ласкаво просимо у новий додаток Zulip!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogLinkText": "Ознайомтесь з анонсом у блозі!", + "@upgradeWelcomeDialogLinkText": { + "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "upgradeWelcomeDialogDismiss": "Ходімо!", + "@upgradeWelcomeDialogDismiss": { + "description": "Label for button dismissing dialog shown on first upgrade from the legacy Zulip app." + }, + "initialAnchorSettingTitle": "Де відкривати стрічку повідомлень", + "@initialAnchorSettingTitle": { + "description": "Title of setting controlling initial anchor of message list." + }, + "initialAnchorSettingNewestAlways": "Найновіше повідомлення", + "@initialAnchorSettingNewestAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "initialAnchorSettingFirstUnreadConversations": "Перше непрочитане повідомлення при перегляді бесід, найновіше у інших місцях", + "@initialAnchorSettingFirstUnreadConversations": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "upgradeWelcomeDialogMessage": "Ви знайдете звичні можливості у більш швидкому і легкому додатку.", + "@upgradeWelcomeDialogMessage": { + "description": "Message text for dialog shown on first upgrade from the legacy Zulip app." + }, + "initialAnchorSettingDescription": "Можна відкривати стрічку повідомлень на першому непрочитаному повідомленні або на найновішому.", + "@initialAnchorSettingDescription": { + "description": "Description of setting controlling initial anchor of message list." + }, + "markReadOnScrollSettingDescription": "При прокручуванні повідомлень автоматично відмічати їх як прочитані?", + "@markReadOnScrollSettingDescription": { + "description": "Description of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingNever": "Ніколи", + "@markReadOnScrollSettingNever": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingConversations": "Тільки при перегляді бесід", + "@markReadOnScrollSettingConversations": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingAlways": "Завжди", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." + }, + "markReadOnScrollSettingTitle": "Відмічати повідомлення як прочитані при прокручуванні", + "@markReadOnScrollSettingTitle": { + "description": "Title of setting controlling which message-list views should mark read on scroll." + }, + "actionSheetOptionQuoteMessage": "Цитувати повідомлення", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "discardDraftForOutboxConfirmationDialogMessage": "При відновленні невідправленого повідомлення, вміст поля редагування очищається.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." + }, + "markReadOnScrollSettingConversationsDescription": "Повідомлення будуть автоматично помічатися як прочитані тільки при перегляді окремої теми або особистої бесіди.", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." } } diff --git a/assets/l10n/app_zh_Hans_CN.arb b/assets/l10n/app_zh_Hans_CN.arb index ed69705ca3..25336275eb 100644 --- a/assets/l10n/app_zh_Hans_CN.arb +++ b/assets/l10n/app_zh_Hans_CN.arb @@ -405,7 +405,7 @@ "@initialAnchorSettingNewestAlways": { "description": "Label for a value of setting controlling initial anchor of message list." }, - "initialAnchorSettingFirstUnreadConversations": "在单个话题或私信中,从第一条未读消息开始;在其他情况下,从最新消息开始", + "initialAnchorSettingFirstUnreadConversations": "在单个话题或私信的第一条未读消息;在其他情况下的最新消息", "@initialAnchorSettingFirstUnreadConversations": { "description": "Label for a value of setting controlling initial anchor of message list." }, diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb index decbe1c885..3ad2c13bc0 100644 --- a/assets/l10n/app_zh_Hant_TW.arb +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -29,15 +29,15 @@ "@switchAccountButton": { "description": "Label for main-menu button leading to the choose-account page." }, - "actionSheetOptionListOfTopics": "主題列表", + "actionSheetOptionListOfTopics": "話題列表", "@actionSheetOptionListOfTopics": { "description": "Label for navigating to a channel's topic-list page." }, - "actionSheetOptionMuteTopic": "將主題設為靜音", + "actionSheetOptionMuteTopic": "靜音話題", "@actionSheetOptionMuteTopic": { "description": "Label for muting a topic on action sheet." }, - "actionSheetOptionResolveTopic": "標註為解決了", + "actionSheetOptionResolveTopic": "標註為已解決", "@actionSheetOptionResolveTopic": { "description": "Label for the 'Mark as resolved' button on the topic action sheet." }, @@ -49,7 +49,7 @@ "@aboutPageTapToView": { "description": "Item subtitle in About Zulip page to navigate to Licenses page" }, - "aboutPageOpenSourceLicenses": "開源軟體授權條款", + "aboutPageOpenSourceLicenses": "開源授權條款", "@aboutPageOpenSourceLicenses": { "description": "Item title in About Zulip page to navigate to Licenses page" }, @@ -65,7 +65,7 @@ "@profileButtonSendDirectMessage": { "description": "Label for button in profile screen to navigate to DMs with the shown user." }, - "chooseAccountButtonAddAnAccount": "新增帳號", + "chooseAccountButtonAddAnAccount": "增添帳號", "@chooseAccountButtonAddAnAccount": { "description": "Label for ChooseAccountPage button to add an account" }, @@ -77,11 +77,11 @@ "@permissionsNeededOpenSettings": { "description": "Button label for permissions dialog button that opens the system settings screen." }, - "actionSheetOptionMarkChannelAsRead": "標註頻道已讀", + "actionSheetOptionMarkChannelAsRead": "標註頻道為已讀", "@actionSheetOptionMarkChannelAsRead": { "description": "Label for marking a channel as read." }, - "actionSheetOptionUnmuteTopic": "將主題取消靜音", + "actionSheetOptionUnmuteTopic": "取消靜音話題", "@actionSheetOptionUnmuteTopic": { "description": "Label for unmuting a topic on action sheet." }, @@ -89,11 +89,11 @@ "@actionSheetOptionUnresolveTopic": { "description": "Label for the 'Mark as unresolved' button on the topic action sheet." }, - "errorResolveTopicFailedTitle": "無法標註為解決了", + "errorResolveTopicFailedTitle": "無法標註話題為已解決", "@errorResolveTopicFailedTitle": { "description": "Error title when marking a topic as resolved failed." }, - "errorUnresolveTopicFailedTitle": "無法標註為未解決", + "errorUnresolveTopicFailedTitle": "無法標註話題為未解決", "@errorUnresolveTopicFailedTitle": { "description": "Error title when marking a topic as unresolved failed." }, @@ -105,11 +105,11 @@ "@actionSheetOptionCopyMessageLink": { "description": "Label for copy message link button on action sheet." }, - "actionSheetOptionMarkAsUnread": "從這裡開始註記為未讀", + "actionSheetOptionMarkAsUnread": "從這裡開始標註為未讀", "@actionSheetOptionMarkAsUnread": { "description": "Label for mark as unread button on action sheet." }, - "actionSheetOptionMarkTopicAsRead": "標註主題為已讀", + "actionSheetOptionMarkTopicAsRead": "標註話題為已讀", "@actionSheetOptionMarkTopicAsRead": { "description": "Option to mark a specific topic as read in the action sheet." }, @@ -117,11 +117,11 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, - "actionSheetOptionStarMessage": "標註為重要訊息", + "actionSheetOptionStarMessage": "收藏訊息", "@actionSheetOptionStarMessage": { "description": "Label for star button on action sheet." }, - "actionSheetOptionUnstarMessage": "取消標註為重要訊息", + "actionSheetOptionUnstarMessage": "取消收藏訊息", "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, @@ -154,5 +154,389 @@ "example": "https://example.com" } } + }, + "initialAnchorSettingFirstUnreadAlways": "第一則未讀訊息", + "@initialAnchorSettingFirstUnreadAlways": { + "description": "Label for a value of setting controlling initial anchor of message list." + }, + "actionSheetOptionUnfollowTopic": "取消跟隨話題", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "errorUnmuteTopicFailed": "無法取消靜音話題", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorMuteTopicFailed": "無法靜音話題", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorUnstarMessageFailedTitle": "無法取消收藏訊息", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, + "successLinkCopied": "已複製連結", + "@successLinkCopied": { + "description": "Success message after copy link action completed." + }, + "successMessageLinkCopied": "已複製訊息連結", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, + "composeBoxBannerButtonCancel": "取消", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxAttachMediaTooltip": "附加圖片或影片", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "loginPageTitle": "登入", + "@loginPageTitle": { + "description": "Title for login page." + }, + "loginHidePassword": "隱藏密碼", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "loginErrorMissingUsername": "請輸入您的使用者名稱。", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "userRoleMember": "成員", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "wildcardMentionTopic": "topic", + "@wildcardMentionTopic": { + "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message." + }, + "emojiPickerSearchEmoji": "搜尋表情符號", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." + }, + "actionSheetOptionFollowTopic": "跟隨話題", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "errorUnfollowTopicFailed": "無法取消跟隨話題", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, + "errorStarMessageFailedTitle": "無法收藏訊息", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "editAlreadyInProgressTitle": "無法編輯訊息", + "@editAlreadyInProgressTitle": { + "description": "Error title when a message edit cannot be saved because there is another edit already in progress." + }, + "errorCouldNotEditMessageTitle": "無法編輯訊息", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, + "composeBoxGroupDmContentHint": "訊息群組", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxChannelContentHint": "訊息 {destination}", + "@composeBoxChannelContentHint": { + "description": "Hint text for content input when sending a message to a channel.", + "placeholders": { + "destination": { + "type": "String", + "example": "#channel name > topic name" + } + } + }, + "errorDialogLearnMore": "了解更多", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, + "loginEmailLabel": "電子郵件地址", + "@loginEmailLabel": { + "description": "Label for input when an email is required to log in." + }, + "markAllAsReadLabel": "標註所有訊息為已讀", + "@markAllAsReadLabel": { + "description": "Button text to mark messages as read." + }, + "wildcardMentionChannel": "channel", + "@wildcardMentionChannel": { + "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message." + }, + "themeSettingDark": "深色主題", + "@themeSettingDark": { + "description": "Label for dark theme setting." + }, + "themeSettingSystem": "系統主題", + "@themeSettingSystem": { + "description": "Label for system theme setting." + }, + "actionSheetOptionHideMutedMessage": "再次隱藏已靜音的話題", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "errorQuotationFailed": "引述失敗", + "@errorQuotationFailed": { + "description": "Error message when quoting a message failed." + }, + "successMessageTextCopied": "已複製訊息文字", + "@successMessageTextCopied": { + "description": "Message when content of a message was copied to the user's system clipboard." + }, + "composeBoxBannerLabelEditMessage": "編輯訊息", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxAttachFilesTooltip": "附加檔案", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "newDmSheetScreenTitle": "新增私訊", + "@newDmSheetScreenTitle": { + "description": "Title displayed at the top of the new DM screen." + }, + "newDmFabButtonLabel": "新增私訊", + "@newDmFabButtonLabel": { + "description": "Label for the floating action button (FAB) that opens the new DM sheet." + }, + "dialogCancel": "取消", + "@dialogCancel": { + "description": "Button label in dialogs to cancel." + }, + "dialogContinue": "繼續", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "loginFormSubmitLabel": "登入", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "signInWithFoo": "使用 {method} 登入", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": { + "type": "String", + "example": "Google" + } + } + }, + "loginPasswordLabel": "密碼", + "@loginPasswordLabel": { + "description": "Label for password input field." + }, + "loginServerUrlLabel": "您的 Zulip 伺服器網址", + "@loginServerUrlLabel": { + "description": "Label in login page for Zulip server URL entry." + }, + "loginUsernameLabel": "使用者名稱", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to log in." + }, + "yesterday": "昨天", + "@yesterday": { + "description": "Term to use to reference the previous day." + }, + "userRoleOwner": "擁有者", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleAdministrator": "管理員", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "userRoleModerator": "版主", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "errorFollowTopicFailed": "無法跟隨話題", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "actionSheetOptionQuoteMessage": "引述訊息", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message action sheet." + }, + "recentDmConversationsPageTitle": "私人訊息", + "@recentDmConversationsPageTitle": { + "description": "Title for the page with a list of DM conversations." + }, + "composeBoxTopicHintText": "話題", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "today": "今天", + "@today": { + "description": "Term to use to reference the current day." + }, + "channelsPageTitle": "頻道", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "loginErrorMissingPassword": "請輸入您的密碼。", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "userRoleGuest": "訪客", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "mentionsPageTitle": "提及", + "@mentionsPageTitle": { + "description": "Page title for the 'Mentions' message view." + }, + "recentDmConversationsSectionHeader": "私人訊息", + "@recentDmConversationsSectionHeader": { + "description": "Heading for direct messages section on the 'Inbox' message view." + }, + "composeBoxDmContentHint": "訊息 @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "channel name" + } + } + }, + "dialogClose": "關閉", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, + "loginErrorMissingEmail": "請輸入您的電子郵件地址。", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "lightboxCopyLinkTooltip": "複製連結", + "@lightboxCopyLinkTooltip": { + "description": "Tooltip in lightbox for the copy link action." + }, + "composeBoxUploadingFilename": "正在上傳 {filename}…", + "@composeBoxUploadingFilename": { + "description": "Placeholder in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "topicsButtonLabel": "話題", + "@topicsButtonLabel": { + "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "themeSettingLight": "淺色主題", + "@themeSettingLight": { + "description": "Label for light theme setting." + }, + "themeSettingTitle": "主題", + "@themeSettingTitle": { + "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "errorVideoPlayerFailed": "無法播放影片。", + "@errorVideoPlayerFailed": { + "description": "Error message when a video fails to play." + }, + "errorDialogTitle": "錯誤", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, + "wildcardMentionChannelDescription": "通知頻道", + "@wildcardMentionChannelDescription": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message." + }, + "upgradeWelcomeDialogTitle": "歡迎使用新 Zulip 應用程式!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "errorCouldNotOpenLinkTitle": "無法開啟連結", + "@errorCouldNotOpenLinkTitle": { + "description": "Error title when opening a link failed." + }, + "emojiReactionsMore": "更多", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "errorSharingFailed": "分享失敗。", + "@errorSharingFailed": { + "description": "Error message when sharing a message failed." + }, + "contentValidationErrorUploadInProgress": "請等待上傳完成。", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, + "newDmSheetSearchHintEmpty": "增添一個或多個使用者", + "@newDmSheetSearchHintEmpty": { + "description": "Hint text for the search bar when no users are selected" + }, + "contentValidationErrorQuoteAndReplyInProgress": "請等待引述完成。", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "errorLoginFailedTitle": "登入失敗", + "@errorLoginFailedTitle": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorNetworkRequestFailed": "網路請求失敗", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "serverUrlValidationErrorInvalidUrl": "請輸入有效的網址。", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "errorCopyingFailed": "複製失敗", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's system clipboard failed." + }, + "serverUrlValidationErrorNoUseEmail": "請輸入伺服器網址,而非您的電子郵件。", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "serverUrlValidationErrorEmpty": "請輸入網址。", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "errorMessageDoesNotSeemToExist": "該訊息似乎不存在。", + "@errorMessageDoesNotSeemToExist": { + "description": "Error message when loading a message that does not exist." + }, + "errorCouldNotOpenLink": "無法開啟連結: {url}", + "@errorCouldNotOpenLink": { + "description": "Error message when opening a link failed.", + "placeholders": { + "url": { + "type": "String", + "example": "https://chat.example.com" + } + } + }, + "spoilerDefaultHeaderText": "劇透", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, + "markAsUnreadInProgress": "正在標註訊息為未讀…", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorLoginCouldNotConnect": "無法連線到伺服器:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": { + "type": "String", + "example": "http://example.com/" + } + } + }, + "errorCouldNotConnectTitle": "無法連線", + "@errorCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, + "errorInvalidResponse": "伺服器傳送了無效的請求。", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." } } diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 2fbaa4b5b5..a7965d81ad 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -21,18 +21,18 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get aboutPageTapToView => 'Antippen zum Ansehen'; @override - String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + String get upgradeWelcomeDialogTitle => 'Willkommen bei der neuen Zulip-App!'; @override String get upgradeWelcomeDialogMessage => - 'You’ll find a familiar experience in a faster, sleeker package.'; + 'Du wirst ein vertrautes Erlebnis in einer schnelleren, schlankeren App erleben.'; @override String get upgradeWelcomeDialogLinkText => - 'Check out the announcement blog post!'; + 'Sieh dir den Ankündigungs-Blogpost an!'; @override - String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + String get upgradeWelcomeDialogDismiss => 'Los gehts'; @override String get chooseAccountPageTitle => 'Konto auswählen'; @@ -821,7 +821,7 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'Erste ungelesene Nachricht in Einzelunterhaltungen, sonst neueste Nachricht'; + 'Erste ungelesene Nachricht in Unterhaltungsansicht, sonst neueste Nachricht'; @override String get initialAnchorSettingNewestAlways => 'Neueste Nachricht'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 41a4efce29..1a9bd161e0 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -21,18 +21,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get aboutPageTapToView => 'Dotknij, aby pokazać'; @override - String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + String get upgradeWelcomeDialogTitle => 'Witaj w nowej apce Zulip!'; @override String get upgradeWelcomeDialogMessage => - 'You’ll find a familiar experience in a faster, sleeker package.'; + 'Napotkasz na znane rozwiązania, które upakowaliśmy w szybszy i elegancki pakiet.'; @override String get upgradeWelcomeDialogLinkText => - 'Check out the announcement blog post!'; + 'Sprawdź blog pod kątem obwieszczenia!'; @override - String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + String get upgradeWelcomeDialogDismiss => 'Zaczynajmy'; @override String get chooseAccountPageTitle => 'Wybierz konto'; @@ -810,7 +810,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'Pierwsza nieprzeczytana wiadomość w pojedynczej dyskusji, wszędzie indziej najnowsza wiadomość'; + 'Pierwsza nieprzeczytana wiadomość w widoku dyskusji, wszędzie indziej najnowsza wiadomość'; @override String get initialAnchorSettingNewestAlways => 'Najnowsza wiadomość'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 5286c97bdb..fced1a4980 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -814,7 +814,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'Первое непрочитанное сообщение в личных беседах, самое новое в остальных'; + 'Первое непрочитанное сообщение при просмотре бесед, самое новое в остальных местах'; @override String get initialAnchorSettingNewestAlways => 'Самое новое сообщение'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index b566059966..885a18c31a 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -826,7 +826,7 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get initialAnchorSettingFirstUnreadConversations => - 'Prvo neprebrano v zasebnih pogovorih, najnovejše drugje'; + 'Prvo neprebrano v pogovorih, najnovejše drugje'; @override String get initialAnchorSettingNewestAlways => 'Najnovejše sporočilo'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index ca4fc19b35..92bd6b9185 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -21,18 +21,18 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get aboutPageTapToView => 'Натисніть, щоб переглянути'; @override - String get upgradeWelcomeDialogTitle => 'Welcome to the new Zulip app!'; + String get upgradeWelcomeDialogTitle => + 'Ласкаво просимо у новий додаток Zulip!'; @override String get upgradeWelcomeDialogMessage => - 'You’ll find a familiar experience in a faster, sleeker package.'; + 'Ви знайдете звичні можливості у більш швидкому і легкому додатку.'; @override - String get upgradeWelcomeDialogLinkText => - 'Check out the announcement blog post!'; + String get upgradeWelcomeDialogLinkText => 'Ознайомтесь з анонсом у блозі!'; @override - String get upgradeWelcomeDialogDismiss => 'Let\'s go'; + String get upgradeWelcomeDialogDismiss => 'Ходімо!'; @override String get chooseAccountPageTitle => 'Обрати обліковий запис'; @@ -140,7 +140,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionShare => 'Поширити'; @override - String get actionSheetOptionQuoteMessage => 'Quote message'; + String get actionSheetOptionQuoteMessage => 'Цитувати повідомлення'; @override String get actionSheetOptionStarMessage => 'Вибрати повідомлення'; @@ -351,7 +351,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get discardDraftForOutboxConfirmationDialogMessage => - 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; + 'При відновленні невідправленого повідомлення, вміст поля редагування очищається.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Скинути'; @@ -803,42 +803,44 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'У цьому опитуванні ще немає варіантів.'; @override - String get initialAnchorSettingTitle => 'Open message feeds at'; + String get initialAnchorSettingTitle => 'Де відкривати стрічку повідомлень'; @override String get initialAnchorSettingDescription => - 'You can choose whether message feeds open at your first unread message or at the newest messages.'; + 'Можна відкривати стрічку повідомлень на першому непрочитаному повідомленні або на найновішому.'; @override - String get initialAnchorSettingFirstUnreadAlways => 'First unread message'; + String get initialAnchorSettingFirstUnreadAlways => + 'Перше непрочитане повідомлення'; @override String get initialAnchorSettingFirstUnreadConversations => - 'First unread message in conversation views, newest message elsewhere'; + 'Перше непрочитане повідомлення при перегляді бесід, найновіше у інших місцях'; @override - String get initialAnchorSettingNewestAlways => 'Newest message'; + String get initialAnchorSettingNewestAlways => 'Найновіше повідомлення'; @override - String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll'; + String get markReadOnScrollSettingTitle => + 'Відмічати повідомлення як прочитані при прокручуванні'; @override String get markReadOnScrollSettingDescription => - 'When scrolling through messages, should they automatically be marked as read?'; + 'При прокручуванні повідомлень автоматично відмічати їх як прочитані?'; @override - String get markReadOnScrollSettingAlways => 'Always'; + String get markReadOnScrollSettingAlways => 'Завжди'; @override - String get markReadOnScrollSettingNever => 'Never'; + String get markReadOnScrollSettingNever => 'Ніколи'; @override String get markReadOnScrollSettingConversations => - 'Only in conversation views'; + 'Тільки при перегляді бесід'; @override String get markReadOnScrollSettingConversationsDescription => - 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'; + 'Повідомлення будуть автоматично помічатися як прочитані тільки при перегляді окремої теми або особистої бесіди.'; @override String get experimentalFeatureSettingsPageTitle => 'Експериментальні функції'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index ad84f01435..5befa99eea 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -1635,7 +1635,7 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { @override String get initialAnchorSettingFirstUnreadConversations => - '在单个话题或私信中,从第一条未读消息开始;在其他情况下,从最新消息开始'; + '在单个话题或私信的第一条未读消息;在其他情况下的最新消息'; @override String get initialAnchorSettingNewestAlways => '最新消息'; @@ -1717,11 +1717,14 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { String get aboutPageAppVersion => 'App 版本'; @override - String get aboutPageOpenSourceLicenses => '開源軟體授權條款'; + String get aboutPageOpenSourceLicenses => '開源授權條款'; @override String get aboutPageTapToView => '點選查看'; + @override + String get upgradeWelcomeDialogTitle => '歡迎使用新 Zulip 應用程式!'; + @override String get chooseAccountPageTitle => '選取帳號'; @@ -1749,7 +1752,7 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { String get logOutConfirmationDialogConfirmButton => '登出'; @override - String get chooseAccountButtonAddAnAccount => '新增帳號'; + String get chooseAccountButtonAddAnAccount => '增添帳號'; @override String get profileButtonSendDirectMessage => '發送私訊'; @@ -1761,28 +1764,34 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { String get permissionsNeededOpenSettings => '開啟設定'; @override - String get actionSheetOptionMarkChannelAsRead => '標註頻道已讀'; + String get actionSheetOptionMarkChannelAsRead => '標註頻道為已讀'; + + @override + String get actionSheetOptionListOfTopics => '話題列表'; @override - String get actionSheetOptionListOfTopics => '主題列表'; + String get actionSheetOptionMuteTopic => '靜音話題'; @override - String get actionSheetOptionMuteTopic => '將主題設為靜音'; + String get actionSheetOptionUnmuteTopic => '取消靜音話題'; @override - String get actionSheetOptionUnmuteTopic => '將主題取消靜音'; + String get actionSheetOptionFollowTopic => '跟隨話題'; @override - String get actionSheetOptionResolveTopic => '標註為解決了'; + String get actionSheetOptionUnfollowTopic => '取消跟隨話題'; + + @override + String get actionSheetOptionResolveTopic => '標註為已解決'; @override String get actionSheetOptionUnresolveTopic => '標註為未解決'; @override - String get errorResolveTopicFailedTitle => '無法標註為解決了'; + String get errorResolveTopicFailedTitle => '無法標註話題為已解決'; @override - String get errorUnresolveTopicFailedTitle => '無法標註為未解決'; + String get errorUnresolveTopicFailedTitle => '無法標註話題為未解決'; @override String get actionSheetOptionCopyMessageText => '複製訊息文字'; @@ -1791,22 +1800,28 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { String get actionSheetOptionCopyMessageLink => '複製訊息連結'; @override - String get actionSheetOptionMarkAsUnread => '從這裡開始註記為未讀'; + String get actionSheetOptionMarkAsUnread => '從這裡開始標註為未讀'; + + @override + String get actionSheetOptionHideMutedMessage => '再次隱藏已靜音的話題'; @override String get actionSheetOptionShare => '分享'; @override - String get actionSheetOptionStarMessage => '標註為重要訊息'; + String get actionSheetOptionQuoteMessage => '引述訊息'; + + @override + String get actionSheetOptionStarMessage => '收藏訊息'; @override - String get actionSheetOptionUnstarMessage => '取消標註為重要訊息'; + String get actionSheetOptionUnstarMessage => '取消收藏訊息'; @override String get actionSheetOptionEditMessage => '編輯訊息'; @override - String get actionSheetOptionMarkTopicAsRead => '標註主題為已讀'; + String get actionSheetOptionMarkTopicAsRead => '標註話題為已讀'; @override String get errorWebAuthOperationalErrorTitle => '出錯了'; @@ -1821,4 +1836,262 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { String errorAccountLoggedIn(String email, String server) { return '在 $server 的帳號 $email 已經存在帳號清單中。'; } + + @override + String get errorCopyingFailed => '複製失敗'; + + @override + String get errorLoginFailedTitle => '登入失敗'; + + @override + String errorLoginCouldNotConnect(String url) { + return '無法連線到伺服器:\n$url'; + } + + @override + String get errorCouldNotConnectTitle => '無法連線'; + + @override + String get errorMessageDoesNotSeemToExist => '該訊息似乎不存在。'; + + @override + String get errorQuotationFailed => '引述失敗'; + + @override + String get errorCouldNotOpenLinkTitle => '無法開啟連結'; + + @override + String errorCouldNotOpenLink(String url) { + return '無法開啟連結: $url'; + } + + @override + String get errorMuteTopicFailed => '無法靜音話題'; + + @override + String get errorUnmuteTopicFailed => '無法取消靜音話題'; + + @override + String get errorFollowTopicFailed => '無法跟隨話題'; + + @override + String get errorUnfollowTopicFailed => '無法取消跟隨話題'; + + @override + String get errorSharingFailed => '分享失敗。'; + + @override + String get errorStarMessageFailedTitle => '無法收藏訊息'; + + @override + String get errorUnstarMessageFailedTitle => '無法取消收藏訊息'; + + @override + String get errorCouldNotEditMessageTitle => '無法編輯訊息'; + + @override + String get successLinkCopied => '已複製連結'; + + @override + String get successMessageTextCopied => '已複製訊息文字'; + + @override + String get successMessageLinkCopied => '已複製訊息連結'; + + @override + String get composeBoxBannerLabelEditMessage => '編輯訊息'; + + @override + String get composeBoxBannerButtonCancel => '取消'; + + @override + String get editAlreadyInProgressTitle => '無法編輯訊息'; + + @override + String get composeBoxAttachFilesTooltip => '附加檔案'; + + @override + String get composeBoxAttachMediaTooltip => '附加圖片或影片'; + + @override + String get newDmSheetScreenTitle => '新增私訊'; + + @override + String get newDmFabButtonLabel => '新增私訊'; + + @override + String get newDmSheetSearchHintEmpty => '增添一個或多個使用者'; + + @override + String composeBoxDmContentHint(String user) { + return '訊息 @$user'; + } + + @override + String get composeBoxGroupDmContentHint => '訊息群組'; + + @override + String composeBoxChannelContentHint(String destination) { + return '訊息 $destination'; + } + + @override + String get composeBoxTopicHintText => '話題'; + + @override + String composeBoxUploadingFilename(String filename) { + return '正在上傳 $filename…'; + } + + @override + String get contentValidationErrorQuoteAndReplyInProgress => '請等待引述完成。'; + + @override + String get contentValidationErrorUploadInProgress => '請等待上傳完成。'; + + @override + String get dialogCancel => '取消'; + + @override + String get dialogContinue => '繼續'; + + @override + String get dialogClose => '關閉'; + + @override + String get errorDialogLearnMore => '了解更多'; + + @override + String get errorDialogTitle => '錯誤'; + + @override + String get lightboxCopyLinkTooltip => '複製連結'; + + @override + String get loginPageTitle => '登入'; + + @override + String get loginFormSubmitLabel => '登入'; + + @override + String signInWithFoo(String method) { + return '使用 $method 登入'; + } + + @override + String get loginServerUrlLabel => '您的 Zulip 伺服器網址'; + + @override + String get loginHidePassword => '隱藏密碼'; + + @override + String get loginEmailLabel => '電子郵件地址'; + + @override + String get loginErrorMissingEmail => '請輸入您的電子郵件地址。'; + + @override + String get loginPasswordLabel => '密碼'; + + @override + String get loginErrorMissingPassword => '請輸入您的密碼。'; + + @override + String get loginUsernameLabel => '使用者名稱'; + + @override + String get loginErrorMissingUsername => '請輸入您的使用者名稱。'; + + @override + String get errorInvalidResponse => '伺服器傳送了無效的請求。'; + + @override + String get errorNetworkRequestFailed => '網路請求失敗'; + + @override + String get errorVideoPlayerFailed => '無法播放影片。'; + + @override + String get serverUrlValidationErrorEmpty => '請輸入網址。'; + + @override + String get serverUrlValidationErrorInvalidUrl => '請輸入有效的網址。'; + + @override + String get serverUrlValidationErrorNoUseEmail => '請輸入伺服器網址,而非您的電子郵件。'; + + @override + String get spoilerDefaultHeaderText => '劇透'; + + @override + String get markAllAsReadLabel => '標註所有訊息為已讀'; + + @override + String get markAsUnreadInProgress => '正在標註訊息為未讀…'; + + @override + String get today => '今天'; + + @override + String get yesterday => '昨天'; + + @override + String get userRoleOwner => '擁有者'; + + @override + String get userRoleAdministrator => '管理員'; + + @override + String get userRoleModerator => '版主'; + + @override + String get userRoleMember => '成員'; + + @override + String get userRoleGuest => '訪客'; + + @override + String get recentDmConversationsPageTitle => '私人訊息'; + + @override + String get recentDmConversationsSectionHeader => '私人訊息'; + + @override + String get mentionsPageTitle => '提及'; + + @override + String get channelsPageTitle => '頻道'; + + @override + String get topicsButtonLabel => '話題'; + + @override + String get wildcardMentionChannel => 'channel'; + + @override + String get wildcardMentionTopic => 'topic'; + + @override + String get wildcardMentionChannelDescription => '通知頻道'; + + @override + String get themeSettingTitle => '主題'; + + @override + String get themeSettingDark => '深色主題'; + + @override + String get themeSettingLight => '淺色主題'; + + @override + String get themeSettingSystem => '系統主題'; + + @override + String get initialAnchorSettingFirstUnreadAlways => '第一則未讀訊息'; + + @override + String get emojiReactionsMore => '更多'; + + @override + String get emojiPickerSearchEmoji => '搜尋表情符號'; } From 080a7e5bcc12eb58375a9b7105b5d4efa52b8d27 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 1 Jul 2025 13:22:18 -0700 Subject: [PATCH 234/290] msglist test [nfc]: Make a test explicit that it exercises "Combined feed" --- test/model/message_list_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index a19229e4a2..1895855673 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -2627,7 +2627,7 @@ void main() { })); }); - test('recipient headers are maintained consistently', () => awaitFakeAsync((async) async { + test('recipient headers are maintained consistently (Combined feed)', () => awaitFakeAsync((async) async { // TODO test date separators are maintained consistently too // This tests the code that maintains the invariant that recipient headers // are present just where they're required. @@ -2648,7 +2648,7 @@ void main() { eg.dmMessage(id: id, from: eg.selfUser, to: [], timestamp: timestamp); // First, test fetchInitial, where some headers are needed and others not. - await prepare(); + await prepare(narrow: CombinedFeedNarrow()); connection.prepare(json: newestResult( foundOldest: false, messages: [streamMessage(10), streamMessage(11), dmMessage(12)], From 42d8b32e1e9503f13271833eaf2ca6b7738687d5 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 24 Jun 2025 15:08:03 -0600 Subject: [PATCH 235/290] msglist: Show recipient headers on all messages, in Mentions / Starred Fixes: #1637 --- lib/model/message_list.dart | 27 ++++++++++++++++- test/model/message_list_test.dart | 48 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index f30d7fac0a..458a725755 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -105,6 +105,18 @@ enum FetchingStatus { /// /// This comprises much of the guts of [MessageListView]. mixin _MessageSequence { + /// Whether each message should have its own recipient header, + /// even if it's in the same conversation as the previous message. + /// + /// In some message-list views, notably "Mentions" and "Starred", + /// it would be misleading to give the impression that consecutive messages + /// in the same conversation were sent one after the other + /// with no other messages in between. + /// By giving each message its own recipient header (a `true` value for this), + /// we intend to avoid giving that impression. + @visibleForTesting + bool get oneMessagePerBlock; + /// A sequence number for invalidating stale fetches. int generation = 0; @@ -435,7 +447,11 @@ mixin _MessageSequence { required MessageListMessageBaseItem Function(bool canShareSender) buildItem, }) { final bool canShareSender; - if (prevMessage == null || !haveSameRecipient(prevMessage, message)) { + if ( + prevMessage == null + || oneMessagePerBlock + || !haveSameRecipient(prevMessage, message) + ) { items.add(MessageListRecipientHeaderItem(message)); canShareSender = false; } else { @@ -623,6 +639,15 @@ class MessageListView with ChangeNotifier, _MessageSequence { super.dispose(); } + @override bool get oneMessagePerBlock => switch (narrow) { + CombinedFeedNarrow() + || ChannelNarrow() + || TopicNarrow() + || DmNarrow() => false, + MentionsNarrow() + || StarredMessagesNarrow() => true, + }; + /// Whether [message] should actually appear in this message list, /// given that it does belong to the narrow. /// diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 1895855673..2cd1769493 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -2742,6 +2742,53 @@ void main() { checkNotifiedOnce(); })); + group('one message per block?', () { + final channelId = 1; + final topic = 'some topic'; + void doTest({required Narrow narrow, required bool expected}) { + test('$narrow: ${expected ? 'yes' : 'no'}', () => awaitFakeAsync((async) async { + final sender = eg.user(); + final channel = eg.stream(streamId: channelId); + final message1 = eg.streamMessage( + sender: sender, + stream: channel, + topic: topic, + flags: [MessageFlag.starred, MessageFlag.mentioned], + ); + final message2 = eg.streamMessage( + sender: sender, + stream: channel, + topic: topic, + flags: [MessageFlag.starred, MessageFlag.mentioned], + ); + + await prepare( + narrow: narrow, + stream: channel, + ); + connection.prepare(json: newestResult( + foundOldest: false, + messages: [message1, message2], + ).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + + check(model).items.deepEquals(>[ + (it) => it.isA(), + (it) => it.isA(), + if (expected) (it) => it.isA(), + (it) => it.isA(), + ]); + })); + } + + doTest(narrow: CombinedFeedNarrow(), expected: false); + doTest(narrow: ChannelNarrow(channelId), expected: false); + doTest(narrow: TopicNarrow(channelId, eg.t(topic)), expected: false); + doTest(narrow: StarredMessagesNarrow(), expected: true); + doTest(narrow: MentionsNarrow(), expected: true); + }); + test('showSender is maintained correctly', () => awaitFakeAsync((async) async { // TODO(#150): This will get more complicated with message moves. // Until then, we always compute this sequentially from oldest to newest. @@ -3011,6 +3058,7 @@ void checkInvariants(MessageListView model) { for (int j = 0; j < allMessages.length; j++) { bool forcedShowSender = false; if (j == 0 + || model.oneMessagePerBlock || !haveSameRecipient(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() .message.identicalTo(allMessages[j]); From 0cf88f39a46306fc0da9e983070fe8fdcc5cd6fa Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 26 Jun 2025 15:01:07 -0700 Subject: [PATCH 236/290] msglist test: Add missing test for tapping channel in recipient header Adapted from the similar test for tapping the topic: > 'navigates to TopicNarrow on tapping topic in ChannelNarrow' --- test/widgets/message_list_test.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index fd8dd6f10b..c8809b1be5 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1342,6 +1342,33 @@ void main() { tester.widget(find.text('new stream name')); }); + testWidgets('navigates to ChannelNarrow on tapping channel in CombinedFeedNarrow', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final channel = eg.stream(); + final subscription = eg.subscription(channel); + final message = eg.streamMessage(stream: channel, topic: 'topic name'); + await setupMessageListPage(tester, + narrow: CombinedFeedNarrow(), + subscriptions: [subscription], + messages: [message], + navObservers: [navObserver]); + + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.tap(find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text(channel.name))); + await tester.pump(); + check(pushedRoutes).single.isA().page.isA() + .initNarrow.equals(ChannelNarrow(channel.streamId)); + await tester.pumpAndSettle(); + }); + testWidgets('navigates to TopicNarrow on tapping topic in ChannelNarrow', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() From a922f56ee15f4e7d7c35f166c3e91a18015758cc Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 2 Jul 2025 13:24:04 -0700 Subject: [PATCH 237/290] msglist: Tapping a message in Starred or Mentions opens anchored msglist Fixes #1621. --- lib/widgets/message_list.dart | 37 +++++++++++-- test/widgets/message_list_checks.dart | 1 + test/widgets/message_list_test.dart | 76 +++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 39ab1b4f04..18e94f541f 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_color_models/flutter_color_models.dart'; @@ -989,11 +991,15 @@ class _MessageListState extends State with PerAccountStoreAwareStat final header = RecipientHeader(message: data.message, narrow: widget.narrow); return MessageItem( key: ValueKey(data.message.id), + narrow: widget.narrow, header: header, item: data); case MessageListOutboxMessageItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); - return MessageItem(header: header, item: data); + return MessageItem( + narrow: widget.narrow, + header: header, + item: data); } } } @@ -1315,10 +1321,12 @@ class DateSeparator extends StatelessWidget { class MessageItem extends StatelessWidget { const MessageItem({ super.key, + required this.narrow, required this.item, required this.header, }); + final Narrow narrow; final MessageListMessageBaseItem item; final Widget header; @@ -1331,7 +1339,9 @@ class MessageItem extends StatelessWidget { color: designVariables.bgMessageRegular, child: Column(children: [ switch (item) { - MessageListMessageItem() => MessageWithPossibleSender(item: item), + MessageListMessageItem() => MessageWithPossibleSender( + narrow: narrow, + item: item), MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item), }, // TODO refine this padding; discussion: @@ -1748,8 +1758,13 @@ class _SenderRow extends StatelessWidget { // - https://github.com/zulip/zulip-mobile/issues/5511 // - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev class MessageWithPossibleSender extends StatelessWidget { - const MessageWithPossibleSender({super.key, required this.item}); + const MessageWithPossibleSender({ + super.key, + required this.narrow, + required this.item, + }); + final Narrow narrow; final MessageListMessageItem item; @override @@ -1798,8 +1813,24 @@ class MessageWithPossibleSender extends StatelessWidget { } } + final tapOpensConversation = switch (narrow) { + CombinedFeedNarrow() + || ChannelNarrow() + || TopicNarrow() + || DmNarrow() => false, + MentionsNarrow() + || StarredMessagesNarrow() => true, + }; + return GestureDetector( behavior: HitTestBehavior.translucent, + onTap: tapOpensConversation + ? () => unawaited(Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), + // TODO(#1655) "this view does not mark messages as read on scroll" + initAnchorMessageId: message.id))) + : null, onLongPress: () => showMessageActionSheet(context: context, message: message), child: Padding( padding: const EdgeInsets.only(top: 4), diff --git a/test/widgets/message_list_checks.dart b/test/widgets/message_list_checks.dart index 6ce43a2d43..0f736466f1 100644 --- a/test/widgets/message_list_checks.dart +++ b/test/widgets/message_list_checks.dart @@ -4,4 +4,5 @@ import 'package:zulip/widgets/message_list.dart'; extension MessageListPageChecks on Subject { Subject get initNarrow => has((x) => x.initNarrow, 'initNarrow'); + Subject get initAnchorMessageId => has((x) => x.initAnchorMessageId, 'initAnchorMessageId'); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index c8809b1be5..da5bda6b65 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1653,6 +1653,82 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('Opens conversation on tap?', () { + // (copied from test/widgets/content_test.dart) + Future tapText(WidgetTester tester, Finder textFinder) async { + final height = tester.getSize(textFinder).height; + final target = tester.getTopLeft(textFinder) + .translate(height/4, height/2); // aim for middle of first letter + await tester.tapAt(target); + } + + final subscription = eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId)); + final topic = 'some topic'; + + void doTest(Narrow narrow, { + required bool expected, + required Message Function() mkMessage, + }) { + testWidgets('${expected ? 'yes' : 'no'}, if in $narrow', (tester) async { + final message = mkMessage(); + + Route? lastPushedRoute; + final navObserver = TestNavigatorObserver() + ..onPushed = ((route, prevRoute) => lastPushedRoute = route); + + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + subscriptions: [subscription], + navObservers: [navObserver] + ); + lastPushedRoute = null; + + // Tapping interactive content still works. + await store.handleEvent(eg.updateMessageEditEvent(message, + renderedContent: '

link

')); + await tester.pump(); + await tapText(tester, find.text('link')); + await tester.pump(Duration.zero); + check(lastPushedRoute).isNull(); + final launchUrlCalls = testBinding.takeLaunchUrlCalls(); + check(launchUrlCalls.single.url).equals(Uri.parse('https://example/')); + + // Tapping non-interactive content opens the conversation (if expected). + await store.handleEvent(eg.updateMessageEditEvent(message, + renderedContent: '

plain content

')); + await tester.pump(); + await tapText(tester, find.text('plain content')); + if (expected) { + final expectedNarrow = SendableNarrow.ofMessage(message, selfUserId: store.selfUserId); + + check(lastPushedRoute).isNotNull().isA() + .page.isA() + ..initNarrow.equals(expectedNarrow) + ..initAnchorMessageId.equals(message.id); + } else { + check(lastPushedRoute).isNull(); + } + + // TODO test tapping whitespace in message + }); + } + + doTest(expected: false, CombinedFeedNarrow(), + mkMessage: () => eg.streamMessage()); + doTest(expected: false, ChannelNarrow(subscription.streamId), + mkMessage: () => eg.streamMessage(stream: subscription)); + doTest(expected: false, TopicNarrow(subscription.streamId, eg.t(topic)), + mkMessage: () => eg.streamMessage(stream: subscription)); + doTest(expected: false, DmNarrow.withUsers([], selfUserId: eg.selfUser.userId), + mkMessage: () => eg.streamMessage(stream: subscription, topic: topic)); + doTest(expected: true, StarredMessagesNarrow(), + mkMessage: () => eg.streamMessage(flags: [MessageFlag.starred])); + doTest(expected: true, MentionsNarrow(), + mkMessage: () => eg.streamMessage(flags: [MessageFlag.mentioned])); + }); }); group('OutboxMessageWithPossibleSender', () { From c4a0ad97d5340ce42b068449a3ff656f790582e3 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sun, 8 Jun 2025 18:22:38 -0700 Subject: [PATCH 238/290] users: Have userDisplayEmail handle unknown users Like userDisplayName does. And remove a null-check `store.getUser(userId)!` at one of the callers... I think that's *probably* NFC, for the reason given in a comment ("must exist because UserMentionAutocompleteResult"). But it's possible this is actually a small bugfix involving a rare race involving our batch-processing of autocomplete results. Related: #716 --- lib/model/store.dart | 9 ++++++--- lib/widgets/autocomplete.dart | 5 ++--- lib/widgets/profile.dart | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 7551c8be85..b3ce59206a 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -693,10 +693,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; } - /// The given user's real email address, if known, for displaying in the UI. + /// The user's real email address, if known, for displaying in the UI. /// - /// Returns null if self-user isn't able to see [user]'s real email address. - String? userDisplayEmail(User user) { + /// Returns null if self-user isn't able to see the user's real email address, + /// or if the user isn't actually a user we know about. + String? userDisplayEmail(int userId) { + final user = getUser(userId); + if (user == null) return null; if (zulipFeatureLevel >= 163) { // TODO(server-7) // A non-null value means self-user has access to [user]'s real email, // while a null value means it doesn't have access to the email. diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 676b30a45c..bfb633ee66 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -275,10 +275,9 @@ class _MentionAutocompleteItem extends StatelessWidget { String? sublabel; switch (option) { case UserMentionAutocompleteResult(:var userId): - final user = store.getUser(userId)!; // must exist because UserMentionAutocompleteResult avatar = Avatar(userId: userId, size: 36, borderRadius: 4); - label = user.fullName; - sublabel = store.userDisplayEmail(user); + label = store.userDisplayName(userId); + sublabel = store.userDisplayEmail(userId); case WildcardMentionAutocompleteResult(:var wildcardOption): avatar = SizedBox.square(dimension: 36, child: const Icon(ZulipIcons.three_person, size: 24)); diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 6c8e8b0b5e..fc9b17fd05 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -47,7 +47,7 @@ class ProfilePage extends StatelessWidget { final nameStyle = _TextStyles.primaryFieldText .merge(weightVariableTextStyle(context, wght: 700)); - final displayEmail = store.userDisplayEmail(user); + final displayEmail = store.userDisplayEmail(userId); final items = [ Center( child: Avatar( From cf857e0df0393093678f235e7c23a6ca6b1a7d8b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sun, 8 Jun 2025 19:26:14 -0700 Subject: [PATCH 239/290] lightbox: Use senderDisplayName for sender's name Related: #716 --- lib/widgets/lightbox.dart | 3 +- test/widgets/lightbox_test.dart | 52 ++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 5b51d3e909..6c0d95aeb3 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -166,6 +166,7 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); final themeData = Theme.of(context); final appBarBackgroundColor = Colors.grey.shade900.withValues(alpha: 0.87); @@ -200,7 +201,7 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { child: RichText( text: TextSpan(children: [ TextSpan( - text: '${widget.message.senderFullName}\n', // TODO(#716): use `store.senderDisplayName` + text: '${store.senderDisplayName(widget.message)}\n', // Restate default style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)), diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 3165222c45..7ccde30032 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:video_player/video_player.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; @@ -205,6 +206,8 @@ void main() { TestZulipBinding.ensureInitialized(); MessageListPage.debugEnableMarkReadOnScroll = false; + late PerAccountStore store; + group('LightboxHero', () { late PerAccountStore store; late FakeApiConnection connection; @@ -317,10 +320,16 @@ void main() { Future setupPage(WidgetTester tester, { Message? message, + List? users, required Uri? thumbnailUrl, }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + if (users != null) { + await store.addUsers(users); + } // ZulipApp instead of TestZulipApp because we need the navigator to push // the lightbox route. The lightbox page works together with the route; @@ -352,20 +361,41 @@ void main() { debugNetworkImageHttpClientProvider = null; }); - testWidgets('app bar shows sender name and date', (tester) async { - prepareBoringImageHttpClient(); - final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; - final message = eg.streamMessage(sender: eg.otherUser, timestamp: timestamp); - await setupPage(tester, message: message, thumbnailUrl: null); - - // We're looking for a RichText, in the app bar, with both the - // sender's name and the timestamp. + void checkAppBarNameAndDate(WidgetTester tester, String expectedName, String expectedDate) { final labelTextWidget = tester.widget( find.descendant(of: find.byType(AppBar).last, - matching: find.textContaining(findRichText: true, - eg.otherUser.fullName))); + matching: find.textContaining(findRichText: true, expectedName))); check(labelTextWidget.text.toPlainText()) - .contains('Jul 23, 2024 23:12:24'); + .contains(expectedDate); + } + + testWidgets('app bar shows sender name and date; updates when name changes', (tester) async { + prepareBoringImageHttpClient(); + final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; + final sender = eg.user(fullName: 'Old name'); + final message = eg.streamMessage(sender: sender, timestamp: timestamp); + await setupPage(tester, message: message, thumbnailUrl: null, users: [sender]); + check(store.getUser(sender.userId)).isNotNull(); + + checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 23:12:24'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: sender.userId, fullName: 'New name')); + await tester.pump(); + checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 23:12:24'); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('app bar shows sender name and date; unknown sender', (tester) async { + prepareBoringImageHttpClient(); + final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; + final sender = eg.user(fullName: 'Sender name'); + final message = eg.streamMessage(sender: sender, timestamp: timestamp); + await setupPage(tester, message: message, thumbnailUrl: null, users: []); + check(store.getUser(sender.userId)).isNull(); + + checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 23:12:24'); debugNetworkImageHttpClientProvider = null; }); From 2ef40707535a34d331b0ba5d87b4d9176fe8c9c7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sun, 8 Jun 2025 20:28:30 -0700 Subject: [PATCH 240/290] compose: Fix error on quote-and-replying message from unknown sender Related: #716 --- lib/model/compose.dart | 43 +++++++++++++++++++++++++----------- test/model/compose_test.dart | 39 ++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/lib/model/compose.dart b/lib/model/compose.dart index 2aa2ed9f44..ccec67e623 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -130,14 +130,33 @@ String wrapWithBacktickFence({required String content, String? infoString}) { /// To omit the user ID part ("|13313") whenever the name part is unambiguous, /// pass the full UserStore. This means accepting a linear scan /// through all users; avoid it in performance-sensitive codepaths. +/// +/// See also [userMentionFromMessage]. String userMention(User user, {bool silent = false, UserStore? users}) { bool includeUserId = users == null || users.allUsers.where((u) => u.fullName == user.fullName) .take(2).length == 2; - - return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**'; + return _userMentionImpl( + silent: silent, + fullName: user.fullName, + userId: includeUserId ? user.userId : null); } +/// An @-mention of an individual user, like @**Chris Bobbe|13313**, +/// from sender data in a [Message]. +/// +/// The user ID part ("|13313") is always included. +/// +/// See also [userMention]. +String userMentionFromMessage(Message message, {bool silent = false, required UserStore users}) => + _userMentionImpl( + silent: silent, + fullName: users.senderDisplayName(message), + userId: message.senderId); + +String _userMentionImpl({required bool silent, required String fullName, int? userId}) => + '@${silent ? '_' : ''}**$fullName${userId != null ? '|$userId' : ''}**'; + /// An @-mention of all the users in a conversation, like @**channel**. String wildcardMention(WildcardMentionOption wildcardOption, { required PerAccountStore store, @@ -190,13 +209,11 @@ String quoteAndReplyPlaceholder( PerAccountStore store, { required Message message, }) { - final sender = store.getUser(message.senderId); - assert(sender != null); // TODO(#716): should use `store.senderDisplayName` final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); - // See note in [quoteAndReply] about asking `mention` to omit the | part. - return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(#1285) + return '${userMentionFromMessage(message, silent: true, users: store)} ' + '${inlineLink('said', url)}: ' // TODO(#1285) '*${zulipLocalizations.composeBoxLoadingMessage(message.id)}*\n'; } @@ -212,14 +229,14 @@ String quoteAndReply(PerAccountStore store, { required Message message, required String rawContent, }) { - final sender = store.getUser(message.senderId); - assert(sender != null); // TODO(#716): should use `store.senderDisplayName` final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); - // Could ask `mention` to omit the | part unless the mention is ambiguous… - // but that would mean a linear scan through all users, and the extra noise - // won't much matter with the already probably-long message link in there too. - return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(#1285) - '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; + // Could ask userMentionFromMessage to omit the | part unless the mention + // is ambiguous… but that would mean a linear scan through all users, + // and the extra noise won't much matter with the already probably-long + // message link in there too. + return '${userMentionFromMessage(message, silent: true, users: store)} ' + '${inlineLink('said', url)}:\n' // TODO(#1285) + '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; } diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index bfbc170ca1..31025809c8 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/store.dart'; @@ -225,26 +226,60 @@ hello group('mention', () { group('user', () { final user = eg.user(userId: 123, fullName: 'Full Name'); - test('not silent', () { + final message = eg.streamMessage(sender: user); + test('not silent', () async { + final store = eg.store(); + await store.addUser(user); check(userMention(user, silent: false)).equals('@**Full Name|123**'); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); }); - test('silent', () { + test('silent', () async { + final store = eg.store(); + await store.addUser(user); check(userMention(user, silent: true)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; has two users with same fullName', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; has two same-name users but one of them is deactivated', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName, isActive: false)]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; user has unique fullName', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 234, fullName: 'Another Name')]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); + }); + + test('userMentionFromMessage, known user', () async { + final user = eg.user(userId: 123, fullName: 'Full Name'); + final store = eg.store(); + await store.addUser(user); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, fullName: 'New Name')); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**New Name|123**'); + }); + + test('userMentionFromMessage, unknown user', () async { + final store = eg.store(); + check(store.getUser(user.userId)).isNull(); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); }); }); From 0b4751d14639912463f8ec2357afa9da46b280b5 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sun, 8 Jun 2025 20:41:53 -0700 Subject: [PATCH 241/290] users [nfc]: Use userDisplayName at last non-self-user sites in widgets/ We've now centralized on store.userDisplayName and store.senderDisplayName for all the code that's responsible for showing a user's name on the screen, except for a few places we use `User.fullName` for (a) the self-user and (b) to create an @-mention for the compose box. The "(unknown user)" and upcoming "Muted user" placeholders aren't needed for (a) or (b). --- lib/widgets/compose_box.dart | 7 ++++--- lib/widgets/profile.dart | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index a53d628a2c..b0018afa1b 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -859,9 +859,10 @@ class _FixedDestinationContentInput extends StatelessWidget { case DmNarrow(otherRecipientIds: [final otherUserId]): final store = PerAccountStoreWidget.of(context); - final fullName = store.getUser(otherUserId)?.fullName; - if (fullName == null) return zulipLocalizations.composeBoxGenericContentHint; - return zulipLocalizations.composeBoxDmContentHint(fullName); + final user = store.getUser(otherUserId); + if (user == null) return zulipLocalizations.composeBoxGenericContentHint; + return zulipLocalizations.composeBoxDmContentHint( + store.userDisplayName(otherUserId)); case DmNarrow(): // A group DM thread. return zulipLocalizations.composeBoxGroupDmContentHint; diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index fc9b17fd05..decef23800 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -65,7 +65,7 @@ class ProfilePage extends StatelessWidget { fontSize: nameStyle.fontSize!, textScaler: MediaQuery.textScalerOf(context), ), - TextSpan(text: user.fullName), + TextSpan(text: store.userDisplayName(userId)), ]), textAlign: TextAlign.center, style: nameStyle), @@ -91,7 +91,7 @@ class ProfilePage extends StatelessWidget { ]; return Scaffold( - appBar: ZulipAppBar(title: Text(user.fullName)), + appBar: ZulipAppBar(title: Text(store.userDisplayName(userId))), body: SingleChildScrollView( child: Center( child: ConstrainedBox( From d93f61adce48c1abdd21955d9aef0df63255cdc8 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sun, 8 Jun 2025 17:50:09 -0700 Subject: [PATCH 242/290] muted-users: Say "Muted user" to replace a user's name, where applicable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (Done by adding an is-muted condition in store.userDisplayName and store.senderDisplayName, with an opt-out param.) If Chris is muted, we'll now show "Muted user" where before we would show "Chris Bobbe", in the following places: - Message-list page: - DM-narrow app bar. - DM recipient headers. - The sender row on messages. This and message content will get more treatment in a separate commit. - Emoji-reaction chips on messages. - Typing indicators ("Muted user is typing…"), but we'll be excluding muted users, coming up in a separate commit. - Voter names in poll messages. - DM items in the Inbox page. (Messages from muted users are automatically marked as read, but they can end up in the inbox if you un-mark them as read.) - The new-DM sheet, but we'll be excluding muted users, coming up in a separate commit. - @-mention autocomplete, but we'll be excluding muted users, coming up in a separate commit. - Items in the Direct messages ("recent DMs") page. We'll be excluding DMs where everyone is muted, coming up in a separate commit. - User items in custom profile fields. We *don't* do this replacement in the following places, i.e., we'll still show "Chris Bobbe" if Chris is muted: - Sender name in the header of the lightbox page. (This follows web.) - The "hint text" for the compose box in a DM narrow: it will still say "Message @Chris Bobbe", not "Message @Muted user". (This follows web.) - The user's name at the top of the Profile page. - We won't generate malformed @-mention syntax like @_**Muted user|13313**. Co-authored-by: Sayed Mahmood Sayedi --- assets/l10n/app_en.arb | 6 +- lib/generated/l10n/zulip_localizations.dart | 8 +- .../l10n/zulip_localizations_ar.dart | 3 - .../l10n/zulip_localizations_de.dart | 3 - .../l10n/zulip_localizations_en.dart | 3 - .../l10n/zulip_localizations_it.dart | 3 - .../l10n/zulip_localizations_ja.dart | 3 - .../l10n/zulip_localizations_nb.dart | 3 - .../l10n/zulip_localizations_pl.dart | 3 - .../l10n/zulip_localizations_ru.dart | 3 - .../l10n/zulip_localizations_sk.dart | 3 - .../l10n/zulip_localizations_sl.dart | 3 - .../l10n/zulip_localizations_uk.dart | 3 - .../l10n/zulip_localizations_zh.dart | 6 -- lib/model/compose.dart | 2 +- lib/model/user.dart | 27 +++++-- lib/widgets/compose_box.dart | 3 +- lib/widgets/inbox.dart | 1 + lib/widgets/lightbox.dart | 3 +- lib/widgets/profile.dart | 7 +- test/model/compose_test.dart | 9 +++ test/model/test_store.dart | 4 + test/widgets/emoji_reaction_test.dart | 21 +++++ test/widgets/message_list_test.dart | 67 ++++++++++++++++ test/widgets/poll_test.dart | 16 ++++ test/widgets/profile_test.dart | 29 ++++++- .../widgets/recent_dm_conversations_test.dart | 80 +++++++++++++++---- 27 files changed, 242 insertions(+), 80 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index a5bb75779a..38ed544e9c 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1047,17 +1047,13 @@ "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." }, - "mutedSender": "Muted sender", - "@mutedSender": { - "description": "Name for a muted user to display in message list." - }, "revealButtonLabel": "Reveal message for muted sender", "@revealButtonLabel": { "description": "Label for the button revealing hidden message from a muted sender in message list." }, "mutedUser": "Muted user", "@mutedUser": { - "description": "Name for a muted user to display all over the app." + "description": "Text to display in place of a muted user's name." }, "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 241a3bbd16..8ce851694b 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1563,19 +1563,13 @@ abstract class ZulipLocalizations { /// **'No earlier messages'** String get noEarlierMessages; - /// Name for a muted user to display in message list. - /// - /// In en, this message translates to: - /// **'Muted sender'** - String get mutedSender; - /// Label for the button revealing hidden message from a muted sender in message list. /// /// In en, this message translates to: /// **'Reveal message for muted sender'** String get revealButtonLabel; - /// Name for a muted user to display all over the app. + /// Text to display in place of a muted user's name. /// /// In en, this message translates to: /// **'Muted user'** diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index e62354d420..5187ff9ba1 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -854,9 +854,6 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; - @override - String get mutedSender => 'Muted sender'; - @override String get revealButtonLabel => 'Reveal message for muted sender'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index a7965d81ad..8ca5d081e3 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -881,9 +881,6 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get noEarlierMessages => 'Keine früheren Nachrichten'; - @override - String get mutedSender => 'Stummgeschalteter Absender'; - @override String get revealButtonLabel => 'Nachricht für stummgeschalteten Absender anzeigen'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 0178fe9406..de99dd7130 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -854,9 +854,6 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; - @override - String get mutedSender => 'Muted sender'; - @override String get revealButtonLabel => 'Reveal message for muted sender'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 847cf68981..8fc5df768b 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -876,9 +876,6 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get noEarlierMessages => 'Nessun messaggio precedente'; - @override - String get mutedSender => 'Mittente silenziato'; - @override String get revealButtonLabel => 'Mostra messaggio per mittente silenziato'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index d7c84a08cb..b03ad14633 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -854,9 +854,6 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; - @override - String get mutedSender => 'Muted sender'; - @override String get revealButtonLabel => 'Reveal message for muted sender'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 98bad7d7b8..8a9050df7d 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -854,9 +854,6 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; - @override - String get mutedSender => 'Muted sender'; - @override String get revealButtonLabel => 'Reveal message for muted sender'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 1a9bd161e0..4249e4af4c 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -867,9 +867,6 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get noEarlierMessages => 'Brak historii'; - @override - String get mutedSender => 'Wyciszony nadawca'; - @override String get revealButtonLabel => 'Odsłoń wiadomość od wyciszonego użytkownika'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index fced1a4980..7213c05535 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -871,9 +871,6 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get noEarlierMessages => 'Предшествующих сообщений нет'; - @override - String get mutedSender => 'Отключенный отправитель'; - @override String get revealButtonLabel => 'Показать сообщение отключенного отправителя'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 0742cfb143..6414b2e01f 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -856,9 +856,6 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; - @override - String get mutedSender => 'Muted sender'; - @override String get revealButtonLabel => 'Reveal message for muted sender'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 885a18c31a..e6f4275f77 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -883,9 +883,6 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get noEarlierMessages => 'Ni starejših sporočil'; - @override - String get mutedSender => 'Utišan pošiljatelj'; - @override String get revealButtonLabel => 'Prikaži sporočilo utišanega pošiljatelja'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 92bd6b9185..98ba4b11e1 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -871,9 +871,6 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get noEarlierMessages => 'Немає попередніх повідомлень'; - @override - String get mutedSender => 'Заглушений відправник'; - @override String get revealButtonLabel => 'Показати повідомлення заглушеного відправника'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 5befa99eea..c23c5364b6 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -854,9 +854,6 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; - @override - String get mutedSender => 'Muted sender'; - @override String get revealButtonLabel => 'Reveal message for muted sender'; @@ -1687,9 +1684,6 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { @override String get noEarlierMessages => '没有更早的消息了'; - @override - String get mutedSender => '静音发送者'; - @override String get revealButtonLabel => '显示静音用户发送的消息'; diff --git a/lib/model/compose.dart b/lib/model/compose.dart index ccec67e623..f1145e555f 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -151,7 +151,7 @@ String userMention(User user, {bool silent = false, UserStore? users}) { String userMentionFromMessage(Message message, {bool silent = false, required UserStore users}) => _userMentionImpl( silent: silent, - fullName: users.senderDisplayName(message), + fullName: users.senderDisplayName(message, replaceIfMuted: false), userId: message.senderId); String _userMentionImpl({required bool silent, required String fullName, int? userId}) => diff --git a/lib/model/user.dart b/lib/model/user.dart index f5079bfd31..3c68154e22 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -44,27 +44,40 @@ mixin UserStore on PerAccountStoreBase { /// The name to show the given user as in the UI, even for unknown users. /// - /// This is the user's [User.fullName] if the user is known, - /// and otherwise a translation of "(unknown user)". + /// If the user is muted and [replaceIfMuted] is true (the default), + /// this is [ZulipLocalizations.mutedUser]. + /// + /// Otherwise this is the user's [User.fullName] if the user is known, + /// or (if unknown) [ZulipLocalizations.unknownUserName]. /// /// When a [Message] is available which the user sent, /// use [senderDisplayName] instead for a better-informed fallback. - String userDisplayName(int userId) { + String userDisplayName(int userId, {bool replaceIfMuted = true}) { + if (replaceIfMuted && isUserMuted(userId)) { + return GlobalLocalizations.zulipLocalizations.mutedUser; + } return getUser(userId)?.fullName ?? GlobalLocalizations.zulipLocalizations.unknownUserName; } /// The name to show for the given message's sender in the UI. /// - /// If the user is known (see [getUser]), this is their current [User.fullName]. + /// If the sender is muted and [replaceIfMuted] is true (the default), + /// this is [ZulipLocalizations.mutedUser]. + /// + /// Otherwise, if the user is known (see [getUser]), + /// this is their current [User.fullName]. /// If unknown, this uses the fallback value conveniently provided on the /// [Message] object itself, namely [Message.senderFullName]. /// /// For a user who isn't the sender of some known message, /// see [userDisplayName]. - String senderDisplayName(Message message) { - return getUser(message.senderId)?.fullName - ?? message.senderFullName; + String senderDisplayName(Message message, {bool replaceIfMuted = true}) { + final senderId = message.senderId; + if (replaceIfMuted && isUserMuted(senderId)) { + return GlobalLocalizations.zulipLocalizations.mutedUser; + } + return getUser(senderId)?.fullName ?? message.senderFullName; } /// Whether the user with [userId] is muted by the self-user. diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index b0018afa1b..10d2158cb5 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -861,8 +861,9 @@ class _FixedDestinationContentInput extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final user = store.getUser(otherUserId); if (user == null) return zulipLocalizations.composeBoxGenericContentHint; + // TODO write a test where the user is muted return zulipLocalizations.composeBoxDmContentHint( - store.userDisplayName(otherUserId)); + store.userDisplayName(otherUserId, replaceIfMuted: false)); case DmNarrow(): // A group DM thread. return zulipLocalizations.composeBoxGroupDmContentHint; diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index cd1822bbac..7f101a81ce 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -395,6 +395,7 @@ class _DmItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); + // TODO write a test where a/the recipient is muted final title = switch (narrow.otherRecipientIds) { // TODO dedupe with [RecentDmConversationsItem] [] => store.selfUser.fullName, [var otherUserId] => store.userDisplayName(otherUserId), diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 6c0d95aeb3..32a7a0e62e 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -201,7 +201,8 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { child: RichText( text: TextSpan(children: [ TextSpan( - text: '${store.senderDisplayName(widget.message)}\n', + // TODO write a test where the sender is muted + text: '${store.senderDisplayName(widget.message, replaceIfMuted: false)}\n', // Restate default style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)), diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index decef23800..3b94e2e39c 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -65,7 +65,8 @@ class ProfilePage extends StatelessWidget { fontSize: nameStyle.fontSize!, textScaler: MediaQuery.textScalerOf(context), ), - TextSpan(text: store.userDisplayName(userId)), + // TODO write a test where the user is muted + TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), ]), textAlign: TextAlign.center, style: nameStyle), @@ -91,7 +92,9 @@ class ProfilePage extends StatelessWidget { ]; return Scaffold( - appBar: ZulipAppBar(title: Text(store.userDisplayName(userId))), + appBar: ZulipAppBar( + // TODO write a test where the user is muted + title: Text(store.userDisplayName(userId, replaceIfMuted: false))), body: SingleChildScrollView( child: Center( child: ConstrainedBox( diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 31025809c8..2031b69d28 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -281,6 +281,15 @@ hello check(userMentionFromMessage(message, silent: false, users: store)) .equals('@**Full Name|123**'); }); + + test('userMentionFromMessage, muted user', () async { + final store = eg.store(); + await store.addUser(user); + await store.setMutedUsers([user.userId]); + check(store.isUserMuted(user.userId)).isTrue(); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); // not replaced with 'Muted user' + }); }); test('wildcard', () { diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 0196611e1d..d979e737f9 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -267,6 +267,10 @@ extension PerAccountStoreTestExtension on PerAccountStore { } } + Future setMutedUsers(List userIds) async { + await handleEvent(eg.mutedUsersEvent(userIds)); + } + Future addStream(ZulipStream stream) async { await addStreams([stream]); } diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 9ff4849b1b..a8b24414f9 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -228,6 +228,27 @@ void main() { } } } + + testWidgets('show "Muted user" label for muted reactors', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + + await prepare(); + await store.addUsers([user1, user2]); + await store.setMutedUsers([user1.userId]); + await setupChipsInBox(tester, + reactions: [ + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user1.userId}), + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user2.userId}), + ]); + + final reactionChipFinder = find.byType(ReactionChip); + check(reactionChipFinder).findsOne(); + check(find.descendant( + of: reactionChipFinder, + matching: find.text('Muted user, User 2') + )).findsOne(); + }); }); testWidgets('Smoke test for light/dark/lerped', (tester) async { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index da5bda6b65..4ca4d8c50d 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -65,6 +65,7 @@ void main() { GetMessagesResult? fetchResult, List? streams, List? users, + List? mutedUserIds, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, int? zulipFeatureLevel, @@ -87,6 +88,9 @@ void main() { // prepare message list data await store.addUser(eg.selfUser); await store.addUsers(users ?? []); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } if (fetchResult != null) { assert(foundOldest && messageCount == null && messages == null); } else { @@ -324,6 +328,22 @@ void main() { matching: find.text('channel foo')), ).findsOne(); }); + + testWidgets('shows "Muted user" label for muted users in DM narrow', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + narrow: DmNarrow.withOtherUsers([1, 2, 3], selfUserId: 10), + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messageCount: 1, + ); + + check(find.text('DMs with Muted user, User 2, Muted user')).findsOne(); + }); }); group('presents message content appropriately', () { @@ -1450,6 +1470,21 @@ void main() { "${zulipLocalizations.unknownUserName}, ${eg.thirdUser.fullName}"))); }); + testWidgets('show "Muted user" label for muted users', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messages: [eg.dmMessage(from: eg.selfUser, to: [user1, user2, user3])] + ); + + check(find.text('You and Muted user, Muted user, User 2')).findsOne(); + }); + testWidgets('icon color matches text color', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await setupMessageListPage(tester, messages: [ @@ -1654,6 +1689,38 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('Muted sender', () { + void checkMessage(Message message, {required bool expectIsMuted}) { + final mutedLabel = 'Muted user'; + final mutedLabelFinder = find.widgetWithText(MessageWithPossibleSender, + mutedLabel); + + final senderName = store.senderDisplayName(message, replaceIfMuted: false); + assert(senderName != mutedLabel); + final senderNameFinder = find.widgetWithText(MessageWithPossibleSender, + senderName); + + check(mutedLabelFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); + check(senderNameFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + } + + final user = eg.user(userId: 1, fullName: 'User'); + final message = eg.streamMessage(sender: user, + content: '

A message

', reactions: [eg.unicodeEmojiReaction]); + + testWidgets('muted appearance', (tester) async { + await setupMessageListPage(tester, + users: [user], mutedUserIds: [user.userId], messages: [message]); + checkMessage(message, expectIsMuted: true); + }); + + testWidgets('not-muted appearance', (tester) async { + await setupMessageListPage(tester, + users: [user], mutedUserIds: [], messages: [message]); + checkMessage(message, expectIsMuted: false); + }); + }); + group('Opens conversation on tap?', () { // (copied from test/widgets/content_test.dart) Future tapText(WidgetTester tester, Finder textFinder) async { diff --git a/test/widgets/poll_test.dart b/test/widgets/poll_test.dart index a6bd74c77e..8e3d66c3bb 100644 --- a/test/widgets/poll_test.dart +++ b/test/widgets/poll_test.dart @@ -28,12 +28,16 @@ void main() { WidgetTester tester, SubmessageData? submessageContent, { Iterable? users, + List? mutedUserIds, Iterable<(User, int)> voterIdxPairs = const [], }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users ?? [eg.selfUser, eg.otherUser]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } connection = store.connection as FakeApiConnection; message = eg.streamMessage( @@ -96,6 +100,18 @@ void main() { check(findTextAtRow('100', index: 0)).findsOne(); }); + testWidgets('muted voters', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await preparePollWidget(tester, pollWidgetData, + users: [user1, user2], + mutedUserIds: [user2.userId], + voterIdxPairs: [(user1, 0), (user2, 0), (user2, 1)]); + + check(findTextAtRow('(User 1, Muted user)', index: 0)).findsOne(); + check(findTextAtRow('(Muted user)', index: 1)).findsOne(); + }); + testWidgets('show unknown voter', (tester) async { await preparePollWidget(tester, pollWidgetData, users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]); diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 30f6433528..b381d1421c 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -1,10 +1,12 @@ 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:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; @@ -19,9 +21,12 @@ import 'page_checks.dart'; import 'profile_page_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required int pageUserId, List? users, + List? mutedUserIds, List? customProfileFields, Map? realmDefaultExternalAccounts, NavigatorObserver? navigatorObserver, @@ -32,12 +37,15 @@ Future setupPage(WidgetTester tester, { customProfileFields: customProfileFields, realmDefaultExternalAccounts: realmDefaultExternalAccounts); await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.selfUser); if (users != null) { await store.addUsers(users); } + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, @@ -237,6 +245,25 @@ void main() { check(textFinder.evaluate()).length.equals(1); }); + testWidgets('page builds; user field with muted user', (tester) async { + final users = [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: '[2,3]'), + }), + eg.user(userId: 2, fullName: 'test user2'), + eg.user(userId: 3, fullName: 'test user3'), + ]; + + await setupPage(tester, + users: users, + mutedUserIds: [2], + pageUserId: 1, + customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)]); + + check(find.text('Muted user')).findsOne(); + check(find.text('test user3')).findsOne(); + }); + testWidgets('page builds; dm links to correct narrow', (tester) async { final pushedRoutes = >[]; final testNavObserver = TestNavigatorObserver() diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index b7307ef6f2..e543658d55 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -27,6 +27,7 @@ import 'test_app.dart'; Future setupPage(WidgetTester tester, { required List dmMessages, required List users, + List? mutedUserIds, NavigatorObserver? navigatorObserver, String? newNameForSelfUser, }) async { @@ -39,6 +40,9 @@ Future setupPage(WidgetTester tester, { for (final user in users) { await store.addUser(user); } + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await store.addMessages(dmMessages); @@ -238,13 +242,27 @@ void main() { }); group('1:1', () { - testWidgets('has right title/avatar', (tester) async { - final user = eg.user(userId: 1); - final message = eg.dmMessage(from: eg.selfUser, to: [user]); - await setupPage(tester, users: [user], dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, user.fullName); + group('has right title/avatar', () { + testWidgets('non-muted user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, user.fullName); + }); + + testWidgets('muted user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, + users: [user], + mutedUserIds: [user.userId], + dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user'); + }); }); testWidgets('no error when user somehow missing from user store', (tester) async { @@ -292,15 +310,45 @@ void main() { return result; } - testWidgets('has right title/avatar', (tester) async { - final users = usersList(2); - final user0 = users[0]; - final user1 = users[1]; - final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); - await setupPage(tester, users: users, dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + group('has right title/avatar', () { + testWidgets('no users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, users: users, dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + }); + + testWidgets('some users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: users, + mutedUserIds: [user0.userId], + dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user, ${user1.fullName}'); + }); + + testWidgets('all users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: users, + mutedUserIds: [user0.userId, user1.userId], + dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user, Muted user'); + }); }); testWidgets('no error when one user somehow missing from user store', (tester) async { From 8b01b47a4990dc66468b5365a71f4d1a035a06e0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 9 Jun 2025 15:00:44 -0700 Subject: [PATCH 243/290] theme [nfc]: Rename some variables that aren't named variables in Figma We're free to rename these because they don't correspond to named variables in the Figma. These more general names will be used for an avatar placeholder for muted users, coming up. Co-authored-by: Sayed Mahmood Sayedi --- lib/widgets/recent_dm_conversations.dart | 4 +-- lib/widgets/theme.dart | 32 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 28a0561f0d..5758a39d76 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -119,9 +119,9 @@ class RecentDmConversationsItem extends StatelessWidget { // // 'Chris、Greg、Alya' title = narrow.otherRecipientIds.map(store.userDisplayName) .join(', '); - avatar = ColoredBox(color: designVariables.groupDmConversationIconBg, + avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, child: Center( - child: Icon(color: designVariables.groupDmConversationIcon, + child: Icon(color: designVariables.avatarPlaceholderIcon, ZulipIcons.group_dm))); } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index b837780d3f..864f663d54 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -185,11 +185,11 @@ class DesignVariables extends ThemeExtension { bgSearchInput: const Color(0xffe3e3e3), textMessage: const Color(0xff262626), channelColorSwatches: ChannelColorSwatches.light, + avatarPlaceholderBg: const Color(0x33808080), + avatarPlaceholderIcon: Colors.black.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), - groupDmConversationIcon: Colors.black.withValues(alpha: 0.5), - groupDmConversationIconBg: const Color(0x33808080), inboxItemIconMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), loginOrDivider: const Color(0xffdedede), loginOrDividerText: const Color(0xff575757), @@ -261,14 +261,14 @@ class DesignVariables extends ThemeExtension { bgSearchInput: const Color(0xff313131), textMessage: const Color(0xffffffff).withValues(alpha: 0.8), channelColorSwatches: ChannelColorSwatches.dark, + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderBg: const Color(0x33cccccc), + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderIcon: Colors.white.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), // the same as the light mode in Figma contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), // the same as the light mode in Figma // TODO(design-dark) need proper dark-theme color (this is ad hoc) dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIcon: Colors.white.withValues(alpha: 0.5), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIconBg: const Color(0x33cccccc), inboxItemIconMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), loginOrDivider: const Color(0xff424242), loginOrDividerText: const Color(0xffa8a8a8), @@ -341,11 +341,11 @@ class DesignVariables extends ThemeExtension { required this.bgSearchInput, required this.textMessage, required this.channelColorSwatches, + required this.avatarPlaceholderBg, + required this.avatarPlaceholderIcon, required this.contextMenuCancelBg, required this.contextMenuCancelPressedBg, required this.dmHeaderBg, - required this.groupDmConversationIcon, - required this.groupDmConversationIconBg, required this.inboxItemIconMarker, required this.loginOrDivider, required this.loginOrDividerText, @@ -426,11 +426,11 @@ class DesignVariables extends ThemeExtension { final ChannelColorSwatches channelColorSwatches; // Not named variables in Figma; taken from older Figma drafts, or elsewhere. + final Color avatarPlaceholderBg; + final Color avatarPlaceholderIcon; final Color contextMenuCancelBg; // In Figma, but unnamed. final Color contextMenuCancelPressedBg; // In Figma, but unnamed. final Color dmHeaderBg; - final Color groupDmConversationIcon; - final Color groupDmConversationIconBg; final Color inboxItemIconMarker; final Color loginOrDivider; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color loginOrDividerText; // TODO(design-dark) need proper dark-theme color (this is ad hoc) @@ -498,11 +498,11 @@ class DesignVariables extends ThemeExtension { Color? bgSearchInput, Color? textMessage, ChannelColorSwatches? channelColorSwatches, + Color? avatarPlaceholderBg, + Color? avatarPlaceholderIcon, Color? contextMenuCancelBg, Color? contextMenuCancelPressedBg, Color? dmHeaderBg, - Color? groupDmConversationIcon, - Color? groupDmConversationIconBg, Color? inboxItemIconMarker, Color? loginOrDivider, Color? loginOrDividerText, @@ -569,11 +569,11 @@ class DesignVariables extends ThemeExtension { bgSearchInput: bgSearchInput ?? this.bgSearchInput, textMessage: textMessage ?? this.textMessage, channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, + avatarPlaceholderBg: avatarPlaceholderBg ?? this.avatarPlaceholderBg, + avatarPlaceholderIcon: avatarPlaceholderIcon ?? this.avatarPlaceholderIcon, contextMenuCancelBg: contextMenuCancelBg ?? this.contextMenuCancelBg, contextMenuCancelPressedBg: contextMenuCancelPressedBg ?? this.contextMenuCancelPressedBg, dmHeaderBg: dmHeaderBg ?? this.dmHeaderBg, - groupDmConversationIcon: groupDmConversationIcon ?? this.groupDmConversationIcon, - groupDmConversationIconBg: groupDmConversationIconBg ?? this.groupDmConversationIconBg, inboxItemIconMarker: inboxItemIconMarker ?? this.inboxItemIconMarker, loginOrDivider: loginOrDivider ?? this.loginOrDivider, loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, @@ -647,11 +647,11 @@ class DesignVariables extends ThemeExtension { bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, textMessage: Color.lerp(textMessage, other.textMessage, t)!, channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), + avatarPlaceholderBg: Color.lerp(avatarPlaceholderBg, other.avatarPlaceholderBg, t)!, + avatarPlaceholderIcon: Color.lerp(avatarPlaceholderIcon, other.avatarPlaceholderIcon, t)!, contextMenuCancelBg: Color.lerp(contextMenuCancelBg, other.contextMenuCancelBg, t)!, contextMenuCancelPressedBg: Color.lerp(contextMenuCancelPressedBg, other.contextMenuCancelPressedBg, t)!, dmHeaderBg: Color.lerp(dmHeaderBg, other.dmHeaderBg, t)!, - groupDmConversationIcon: Color.lerp(groupDmConversationIcon, other.groupDmConversationIcon, t)!, - groupDmConversationIconBg: Color.lerp(groupDmConversationIconBg, other.groupDmConversationIconBg, t)!, inboxItemIconMarker: Color.lerp(inboxItemIconMarker, other.inboxItemIconMarker, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, From 86137cdbd9baee12854809d24daf354792daf5e9 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 9 Jun 2025 15:38:22 -0700 Subject: [PATCH 244/290] muted-users: Use placeholder for avatars of muted users, where applicable (Done by adding an is-muted condition in Avatar and AvatarImage, with an opt-out param. Similar to how we handled users' names in a recent commit.) If a user is muted, we'll now show a placeholder where before we would have shown their real avatar, in the following places: - The sender row on messages in the message list. This and message content will get more treatment in a separate commit. - @-mention autocomplete, but we'll be excluding muted users, coming up in a separate commit. - User items in custom profile fields. - 1:1 DM items in the Direct messages ("recent DMs") page. But we'll be excluding those items there, coming up in a separate commit. We *don't* do this replacement in the following places, i.e., we'll still show the real avatar: - The header of the lightbox page. (This follows web.) - The big avatar at the top of the profile page. Co-authored-by: Sayed Mahmood Sayedi --- lib/widgets/content.dart | 36 ++++++++++++++++++++++++++++- lib/widgets/lightbox.dart | 9 ++++++-- lib/widgets/profile.dart | 6 +++-- test/widgets/message_list_test.dart | 17 +++++++++++++- test/widgets/profile_test.dart | 24 +++++++++++++++++-- 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index eee41d785e..2966b4dc46 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1666,6 +1666,7 @@ class Avatar extends StatelessWidget { required this.borderRadius, this.backgroundColor, this.showPresence = true, + this.replaceIfMuted = true, }); final int userId; @@ -1673,6 +1674,7 @@ class Avatar extends StatelessWidget { final double borderRadius; final Color? backgroundColor; final bool showPresence; + final bool replaceIfMuted; @override Widget build(BuildContext context) { @@ -1684,7 +1686,7 @@ class Avatar extends StatelessWidget { borderRadius: borderRadius, backgroundColor: backgroundColor, userIdForPresence: showPresence ? userId : null, - child: AvatarImage(userId: userId, size: size)); + child: AvatarImage(userId: userId, size: size, replaceIfMuted: replaceIfMuted)); } } @@ -1698,10 +1700,12 @@ class AvatarImage extends StatelessWidget { super.key, required this.userId, required this.size, + this.replaceIfMuted = true, }); final int userId; final double size; + final bool replaceIfMuted; @override Widget build(BuildContext context) { @@ -1712,6 +1716,10 @@ class AvatarImage extends StatelessWidget { return const SizedBox.shrink(); } + if (replaceIfMuted && store.isUserMuted(userId)) { + return _AvatarPlaceholder(size: size); + } + final resolvedUrl = switch (user.avatarUrl) { null => null, // TODO(#255): handle computing gravatars var avatarUrl => store.tryResolveUrl(avatarUrl), @@ -1732,6 +1740,32 @@ class AvatarImage extends StatelessWidget { } } +/// A placeholder avatar for muted users. +/// +/// Wrap this with [AvatarShape]. +// TODO(#1558) use this as a fallback in more places (?) and update dartdoc. +class _AvatarPlaceholder extends StatelessWidget { + const _AvatarPlaceholder({required this.size}); + + /// The size of the placeholder box. + /// + /// This should match the `size` passed to the wrapping [AvatarShape]. + /// The placeholder's icon will be scaled proportionally to this. + final double size; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return DecoratedBox( + decoration: BoxDecoration(color: designVariables.avatarPlaceholderBg), + child: Icon(ZulipIcons.person, + // Where the avatar placeholder appears in the Figma, + // this is how the icon is sized proportionally to its box. + size: size * 20 / 32, + color: designVariables.avatarPlaceholderIcon)); + } +} + /// A rounded square shape, to wrap an [AvatarImage] or similar. /// /// If [userIdForPresence] is provided, this will paint a [PresenceCircle] diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 32a7a0e62e..7199c72a5c 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -195,13 +195,18 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { shape: const Border(), // Remove bottom border from [AppBarTheme] elevation: appBarElevation, title: Row(children: [ - Avatar(size: 36, borderRadius: 36 / 8, userId: widget.message.senderId), + Avatar( + size: 36, + borderRadius: 36 / 8, + userId: widget.message.senderId, + replaceIfMuted: false, + ), const SizedBox(width: 8), Expanded( child: RichText( text: TextSpan(children: [ TextSpan( - // TODO write a test where the sender is muted + // TODO write a test where the sender is muted; check this and avatar text: '${store.senderDisplayName(widget.message, replaceIfMuted: false)}\n', // Restate default diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 3b94e2e39c..27c8486fe8 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -56,7 +56,9 @@ class ProfilePage extends StatelessWidget { borderRadius: 200 / 8, // Would look odd with this large image; // we'll show it by the user's name instead. - showPresence: false)), + showPresence: false, + replaceIfMuted: false, + )), const SizedBox(height: 16), Text.rich( TextSpan(children: [ @@ -65,7 +67,7 @@ class ProfilePage extends StatelessWidget { fontSize: nameStyle.fontSize!, textScaler: MediaQuery.textScalerOf(context), ), - // TODO write a test where the user is muted + // TODO write a test where the user is muted; check this and avatar TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), ]), textAlign: TextAlign.center, diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 4ca4d8c50d..623e2318e7 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1695,6 +1695,15 @@ void main() { final mutedLabelFinder = find.widgetWithText(MessageWithPossibleSender, mutedLabel); + final avatarFinder = find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == message.senderId); + final mutedAvatarFinder = find.descendant( + of: avatarFinder, + matching: find.byIcon(ZulipIcons.person)); + final nonmutedAvatarFinder = find.descendant( + of: avatarFinder, + matching: find.byType(RealmContentNetworkImage)); + final senderName = store.senderDisplayName(message, replaceIfMuted: false); assert(senderName != mutedLabel); final senderNameFinder = find.widgetWithText(MessageWithPossibleSender, @@ -1702,22 +1711,28 @@ void main() { check(mutedLabelFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); check(senderNameFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + check(mutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); + check(nonmutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); } - final user = eg.user(userId: 1, fullName: 'User'); + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); final message = eg.streamMessage(sender: user, content: '

A message

', reactions: [eg.unicodeEmojiReaction]); testWidgets('muted appearance', (tester) async { + prepareBoringImageHttpClient(); await setupMessageListPage(tester, users: [user], mutedUserIds: [user.userId], messages: [message]); checkMessage(message, expectIsMuted: true); + debugNetworkImageHttpClientProvider = null; }); testWidgets('not-muted appearance', (tester) async { + prepareBoringImageHttpClient(); await setupMessageListPage(tester, users: [user], mutedUserIds: [], messages: [message]); checkMessage(message, expectIsMuted: false); + debugNetworkImageHttpClientProvider = null; }); }); diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index b381d1421c..ac461fe73b 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -8,6 +8,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/profile.dart'; @@ -15,6 +16,7 @@ import 'package:zulip/widgets/profile.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../model/test_store.dart'; +import '../test_images.dart'; import '../test_navigation.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; @@ -246,12 +248,23 @@ void main() { }); testWidgets('page builds; user field with muted user', (tester) async { + prepareBoringImageHttpClient(); + + Finder avatarFinder(int userId) => find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == userId); + Finder mutedAvatarFinder(int userId) => find.descendant( + of: avatarFinder(userId), + matching: find.byIcon(ZulipIcons.person)); + Finder nonmutedAvatarFinder(int userId) => find.descendant( + of: avatarFinder(userId), + matching: find.byType(RealmContentNetworkImage)); + final users = [ eg.user(userId: 1, profileData: { 0: ProfileFieldUserData(value: '[2,3]'), }), - eg.user(userId: 2, fullName: 'test user2'), - eg.user(userId: 3, fullName: 'test user3'), + eg.user(userId: 2, fullName: 'test user2', avatarUrl: '/foo.png'), + eg.user(userId: 3, fullName: 'test user3', avatarUrl: '/bar.png'), ]; await setupPage(tester, @@ -261,7 +274,14 @@ void main() { customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)]); check(find.text('Muted user')).findsOne(); + check(mutedAvatarFinder(2)).findsOne(); + check(nonmutedAvatarFinder(2)).findsNothing(); + check(find.text('test user3')).findsOne(); + check(mutedAvatarFinder(3)).findsNothing(); + check(nonmutedAvatarFinder(3)).findsOne(); + + debugNetworkImageHttpClientProvider = null; }); testWidgets('page builds; dm links to correct narrow', (tester) async { From e672a29663571c54ad33acd86bdfe8e9366350b6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 9 Jun 2025 14:41:57 -0700 Subject: [PATCH 245/290] msglist [nfc]: Remove a no-op MainAxisAlignment.spaceBetween in _SenderRow No-op because the child Flexible -> GestureDetector -> Row has the default MainAxisSize.max, filling the available space, leaving none that would be controlled by spaceBetween. --- lib/widgets/message_list.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 18e94f541f..eb9d554103 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1703,7 +1703,6 @@ class _SenderRow extends StatelessWidget { return Padding( padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), children: [ From cd65877d8457dd417def55a84e1d7042e8d41dcf Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 9 Jun 2025 17:20:32 -0700 Subject: [PATCH 246/290] button [nfc]: Have ZulipWebUiKitButton support a smaller, ad hoc size For muted-users, coming up. This was ad hoc for mobile, for the "Reveal message" button on a message from a muted sender: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev --- lib/widgets/button.dart | 51 +++++++++--- test/widgets/button_test.dart | 146 ++++++++++++++++++---------------- 2 files changed, 120 insertions(+), 77 deletions(-) diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index fb5968b97a..5d4049f87a 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -18,12 +18,14 @@ class ZulipWebUiKitButton extends StatelessWidget { super.key, this.attention = ZulipWebUiKitButtonAttention.medium, this.intent = ZulipWebUiKitButtonIntent.info, + this.size = ZulipWebUiKitButtonSize.normal, required this.label, required this.onPressed, }); final ZulipWebUiKitButtonAttention attention; final ZulipWebUiKitButtonIntent intent; + final ZulipWebUiKitButtonSize size; final String label; final VoidCallback onPressed; @@ -53,7 +55,8 @@ class ZulipWebUiKitButton extends StatelessWidget { TextStyle _labelStyle(BuildContext context, {required TextScaler textScaler}) { final designVariables = DesignVariables.of(context); - // Values chosen from the Figma frame for zulip-flutter's compose box: + // Normal-size values chosen from the Figma frame for zulip-flutter's + // compose box: // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3988-38201&m=dev // Commented values come from the Figma page "Zulip Web UI kit": // https://www.figma.com/design/msWyAJ8cnMHgOMPxi7BUvA/Zulip-Web-UI-kit?node-id=1-8&p=f&m=dev @@ -61,11 +64,14 @@ class ZulipWebUiKitButton extends StatelessWidget { // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023880851 return TextStyle( color: _labelColor(designVariables), - fontSize: 17, // 16 - height: 1.20, // 1.25 - letterSpacing: proportionalLetterSpacing(context, textScaler: textScaler, - 0.006, - baseFontSize: 17), // 16 + fontSize: _forSize(16, 17 /* 16 */), + height: _forSize(1, 1.20 /* 1.25 */), + letterSpacing: _forSize( + 0, + proportionalLetterSpacing(context, textScaler: textScaler, + 0.006, + baseFontSize: 17 /* 16 */), + ), ).merge(weightVariableTextStyle(context, wght: 600)); // 500 } @@ -87,6 +93,12 @@ class ZulipWebUiKitButton extends StatelessWidget { } } + T _forSize(T small, T normal) => + switch (size) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); @@ -104,24 +116,32 @@ class ZulipWebUiKitButton extends StatelessWidget { // from shrinking to zero as the button grows to accommodate a larger label final textScaler = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5); + final buttonHeight = _forSize(24, 28); + return AnimatedScaleOnTap( scaleEnd: 0.96, duration: Duration(milliseconds: 100), child: TextButton( style: TextButton.styleFrom( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4 - densityVerticalAdjustment), + padding: EdgeInsets.symmetric( + horizontal: _forSize(6, 10), + vertical: 4 - densityVerticalAdjustment, + ), foregroundColor: _labelColor(designVariables), shape: RoundedRectangleBorder( side: _borderSide(designVariables), - borderRadius: BorderRadius.circular(4)), + borderRadius: BorderRadius.circular(_forSize(6, 4))), splashFactory: NoSplash.splashFactory, - // These three arguments make the button 28px tall vertically, + // These three arguments make the button `buttonHeight` tall, // but with vertical padding to make the touch target 44px tall: // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023907300 visualDensity: visualDensity, tapTargetSize: MaterialTapTargetSize.padded, - minimumSize: Size(kMinInteractiveDimension, 28 - densityVerticalAdjustment), + minimumSize: Size( + kMinInteractiveDimension, + buttonHeight - densityVerticalAdjustment, + ), ).copyWith(backgroundColor: _backgroundColor(designVariables)), onPressed: onPressed, child: ConstrainedBox( @@ -150,6 +170,17 @@ enum ZulipWebUiKitButtonIntent { // brand, } +enum ZulipWebUiKitButtonSize { + /// A smaller size than the one in the Zulip Web UI Kit. + /// + /// This was ad hoc for mobile, for the "Reveal message" button + /// on a message from a muted sender: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev + small, + + normal, +} + /// Apply [Transform.scale] to the child widget when tapped, and reset its scale /// when released, while animating the transitions. class AnimatedScaleOnTap extends StatefulWidget { diff --git a/test/widgets/button_test.dart b/test/widgets/button_test.dart index da9136b8a2..62f2fad7d1 100644 --- a/test/widgets/button_test.dart +++ b/test/widgets/button_test.dart @@ -16,72 +16,84 @@ void main() { TestZulipBinding.ensureInitialized(); group('ZulipWebUiKitButton', () { - final textScaleFactorVariants = ValueVariant(Set.of(kTextScaleFactors)); - - testWidgets('vertical outer padding is preserved as text scales', (tester) async { - addTearDown(testBinding.reset); - tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - - final buttonFinder = find.byType(ZulipWebUiKitButton); - - await tester.pumpWidget(TestZulipApp( - child: UnconstrainedBox( - child: ZulipWebUiKitButton(label: 'Cancel', onPressed: () {})))); - await tester.pump(); - - final element = tester.element(buttonFinder); - final renderObject = element.renderObject as RenderBox; - final size = renderObject.size; - check(size).height.equals(44); // includes outer padding - - final textScaler = TextScaler.linear(textScaleFactorVariants.currentValue!) - .clamp(maxScaleFactor: 1.5); - final expectedButtonHeight = max(28.0, // configured min height - (textScaler.scale(17) * 1.20).roundToDouble() // text height - + 4 + 4); // vertical padding - - // Rounded rectangle paints with the intended height… - final expectedRRect = RRect.fromLTRBR( - 0, 0, // zero relative to the position at this paint step - size.width, expectedButtonHeight, Radius.circular(4)); - check(renderObject).legacyMatcher( - // `paints` isn't a [Matcher] so we wrap it with `equals`; - // awkward but it works - equals(paints..drrect(outer: expectedRRect))); - - // …and that height leaves at least 4px for vertical outer padding. - check(expectedButtonHeight).isLessOrEqual(44 - 2 - 2); - }, variant: textScaleFactorVariants); - - testWidgets('vertical outer padding responds to taps, not just painted area', (tester) async { - addTearDown(testBinding.reset); - tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - - final buttonFinder = find.byType(ZulipWebUiKitButton); - - int numTapsHandled = 0; - await tester.pumpWidget(TestZulipApp( - child: UnconstrainedBox( - child: ZulipWebUiKitButton( - label: 'Cancel', - onPressed: () => numTapsHandled++)))); - await tester.pump(); - - final element = tester.element(buttonFinder); - final renderObject = element.renderObject as RenderBox; - final size = renderObject.size; - check(size).height.equals(44); // includes outer padding - - // Outer padding responds to taps, not just the painted part. - final buttonCenter = tester.getCenter(buttonFinder); - int numTaps = 0; - for (double y = -22; y < 22; y++) { - await tester.tapAt(buttonCenter + Offset(0, y)); - numTaps++; - } - check(numTapsHandled).equals(numTaps); - }, variant: textScaleFactorVariants); + void testVerticalOuterPadding({required ZulipWebUiKitButtonSize sizeVariant}) { + final textScaleFactorVariants = ValueVariant(Set.of(kTextScaleFactors)); + T forSizeVariant(T small, T normal) => + switch (sizeVariant) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + + testWidgets('vertical outer padding is preserved as text scales; $sizeVariant', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () {}, + size: sizeVariant)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + final textScaler = TextScaler.linear(textScaleFactorVariants.currentValue!) + .clamp(maxScaleFactor: 1.5); + final expectedButtonHeight = max(forSizeVariant(24.0, 28.0), // configured min height + (textScaler.scale(forSizeVariant(16, 17) * forSizeVariant(1, 1.20)).roundToDouble() // text height + + 4 + 4)); // vertical padding + + // Rounded rectangle paints with the intended height… + final expectedRRect = RRect.fromLTRBR( + 0, 0, // zero relative to the position at this paint step + size.width, expectedButtonHeight, Radius.circular(forSizeVariant(6, 4))); + check(renderObject).legacyMatcher( + // `paints` isn't a [Matcher] so we wrap it with `equals`; + // awkward but it works + equals(paints..drrect(outer: expectedRRect))); + + // …and that height leaves at least 4px for vertical outer padding. + check(expectedButtonHeight).isLessOrEqual(44 - 2 - 2); + }, variant: textScaleFactorVariants); + + testWidgets('vertical outer padding responds to taps, not just painted area', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + int numTapsHandled = 0; + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () => numTapsHandled++)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + // Outer padding responds to taps, not just the painted part. + final buttonCenter = tester.getCenter(buttonFinder); + int numTaps = 0; + for (double y = -22; y < 22; y++) { + await tester.tapAt(buttonCenter + Offset(0, y)); + numTaps++; + } + check(numTapsHandled).equals(numTaps); + }, variant: textScaleFactorVariants); + } + testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.small); + testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.normal); }); } From 17294eef351efc0984ccf25640d8c37ed1751371 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 9 Jun 2025 17:25:48 -0700 Subject: [PATCH 247/290] button [nfc]: Have ZulipWebUiKitButton support an icon For muted-users, coming up. This is consistent with the ad hoc design for muted-users, but also consistent with the component in "Zulip Web UI Kit". (Modulo the TODO for changing icon-to-label gap from 8px to 6px; that's tricky with the Material widget we're working with.) --- lib/widgets/button.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 5d4049f87a..749c7b7009 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -20,6 +20,7 @@ class ZulipWebUiKitButton extends StatelessWidget { this.intent = ZulipWebUiKitButtonIntent.info, this.size = ZulipWebUiKitButtonSize.normal, required this.label, + this.icon, required this.onPressed, }); @@ -27,6 +28,7 @@ class ZulipWebUiKitButton extends StatelessWidget { final ZulipWebUiKitButtonIntent intent; final ZulipWebUiKitButtonSize size; final String label; + final IconData? icon; final VoidCallback onPressed; WidgetStateColor _backgroundColor(DesignVariables designVariables) { @@ -118,16 +120,22 @@ class ZulipWebUiKitButton extends StatelessWidget { final buttonHeight = _forSize(24, 28); + final labelColor = _labelColor(designVariables); + return AnimatedScaleOnTap( scaleEnd: 0.96, duration: Duration(milliseconds: 100), - child: TextButton( + child: TextButton.icon( + // TODO the gap between the icon and label should be 6px, not 8px + icon: icon != null ? Icon(icon) : null, style: TextButton.styleFrom( + iconSize: 16, + iconColor: labelColor, padding: EdgeInsets.symmetric( horizontal: _forSize(6, 10), vertical: 4 - densityVerticalAdjustment, ), - foregroundColor: _labelColor(designVariables), + foregroundColor: labelColor, shape: RoundedRectangleBorder( side: _borderSide(designVariables), borderRadius: BorderRadius.circular(_forSize(6, 4))), @@ -144,7 +152,7 @@ class ZulipWebUiKitButton extends StatelessWidget { ), ).copyWith(backgroundColor: _backgroundColor(designVariables)), onPressed: onPressed, - child: ConstrainedBox( + label: ConstrainedBox( constraints: BoxConstraints(maxWidth: 240), child: Text(label, textScaler: textScaler, From 2ca3015fc9c379d8257d34ba9a7084eeba730cd6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 9 Jun 2025 18:54:12 -0700 Subject: [PATCH 248/290] button [nfc]: Have ZulipWebUiKitButton support ad hoc minimal-neutral type For muted-users, coming up. Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50795&m=dev --- lib/widgets/button.dart | 25 ++++++++++++++++++++++++- lib/widgets/theme.dart | 14 ++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 749c7b7009..f142c2fa24 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -33,6 +33,15 @@ class ZulipWebUiKitButton extends StatelessWidget { WidgetStateColor _backgroundColor(DesignVariables designVariables) { switch ((attention, intent)) { + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.neutral): + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.neutralButtonBg.withFadedAlpha(0.3), + ~WidgetState.pressed: designVariables.neutralButtonBg.withAlpha(0), + }); + case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.info): + throw UnimplementedError(); case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.info): return WidgetStateColor.fromMap({ WidgetState.pressed: designVariables.btnBgAttMediumIntInfoActive, @@ -48,6 +57,13 @@ class ZulipWebUiKitButton extends StatelessWidget { Color _labelColor(DesignVariables designVariables) { switch ((attention, intent)) { + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.neutral): + // TODO nit: don't fade in pressed state + return designVariables.neutralButtonLabel.withFadedAlpha(0.85); + case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.info): + throw UnimplementedError(); case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.info): return designVariables.btnLabelAttMediumIntInfo; case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.info): @@ -80,6 +96,8 @@ class ZulipWebUiKitButton extends StatelessWidget { BorderSide _borderSide(DesignVariables designVariables) { switch (attention) { + case ZulipWebUiKitButtonAttention.minimal: + return BorderSide.none; case ZulipWebUiKitButtonAttention.medium: // TODO inner shadow effect like `box-shadow: inset`, following Figma; // needs Flutter support for something like that: @@ -167,10 +185,15 @@ enum ZulipWebUiKitButtonAttention { high, medium, // low, + + /// An ad hoc value for the "Reveal message" button + /// on a message from a muted sender: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev + minimal, } enum ZulipWebUiKitButtonIntent { - // neutral, + neutral, // warning, // danger, info, diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 864f663d54..99a3148027 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -171,6 +171,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xff222222), labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), mainBackground: const Color(0xfff0f0f0), + neutralButtonBg: const Color(0xff8c84ae), + neutralButtonLabel: const Color(0xff433d5c), radioBorder: Color(0xffbbbdc8), radioFillSelected: Color(0xff4370f0), statusAway: Color(0xff73788c).withValues(alpha: 0.25), @@ -247,6 +249,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), mainBackground: const Color(0xff1d1d1d), + neutralButtonBg: const Color(0xffd4d1e0), + neutralButtonLabel: const Color(0xffa9a3c2), radioBorder: Color(0xff626573), radioFillSelected: Color(0xff4e7cfa), statusAway: Color(0xffabaeba).withValues(alpha: 0.30), @@ -331,6 +335,8 @@ class DesignVariables extends ThemeExtension { required this.labelMenuButton, required this.labelSearchPrompt, required this.mainBackground, + required this.neutralButtonBg, + required this.neutralButtonLabel, required this.radioBorder, required this.radioFillSelected, required this.statusAway, @@ -412,6 +418,8 @@ class DesignVariables extends ThemeExtension { final Color labelMenuButton; final Color labelSearchPrompt; final Color mainBackground; + final Color neutralButtonBg; + final Color neutralButtonLabel; final Color radioBorder; final Color radioFillSelected; final Color statusAway; @@ -488,6 +496,8 @@ class DesignVariables extends ThemeExtension { Color? labelMenuButton, Color? labelSearchPrompt, Color? mainBackground, + Color? neutralButtonBg, + Color? neutralButtonLabel, Color? radioBorder, Color? radioFillSelected, Color? statusAway, @@ -559,6 +569,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: labelMenuButton ?? this.labelMenuButton, labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, mainBackground: mainBackground ?? this.mainBackground, + neutralButtonBg: neutralButtonBg ?? this.neutralButtonBg, + neutralButtonLabel: neutralButtonLabel ?? this.neutralButtonLabel, radioBorder: radioBorder ?? this.radioBorder, radioFillSelected: radioFillSelected ?? this.radioFillSelected, statusAway: statusAway ?? this.statusAway, @@ -637,6 +649,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + neutralButtonBg: Color.lerp(neutralButtonBg, other.neutralButtonBg, t)!, + neutralButtonLabel: Color.lerp(neutralButtonLabel, other.neutralButtonLabel, t)!, radioBorder: Color.lerp(radioBorder, other.radioBorder, t)!, radioFillSelected: Color.lerp(radioFillSelected, other.radioFillSelected, t)!, statusAway: Color.lerp(statusAway, other.statusAway, t)!, From b509c24a87c1096d85f716f60ab3a1741644a676 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 9 Jun 2025 13:53:28 -0700 Subject: [PATCH 249/290] msglist: Hide content of muted messages, with a "Reveal message" button Figma design: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6089-28385&t=28DdYiTs6fXWR9ua-0 Co-authored-by: Sayed Mahmood Sayedi --- assets/l10n/app_en.arb | 2 +- assets/l10n/app_pl.arb | 4 - assets/l10n/app_ru.arb | 4 - lib/generated/l10n/zulip_localizations.dart | 2 +- .../l10n/zulip_localizations_ar.dart | 2 +- .../l10n/zulip_localizations_en.dart | 2 +- .../l10n/zulip_localizations_ja.dart | 2 +- .../l10n/zulip_localizations_nb.dart | 2 +- .../l10n/zulip_localizations_pl.dart | 2 +- .../l10n/zulip_localizations_ru.dart | 2 +- .../l10n/zulip_localizations_sk.dart | 2 +- .../l10n/zulip_localizations_zh.dart | 2 +- lib/widgets/action_sheet.dart | 29 ++++ lib/widgets/message_list.dart | 157 ++++++++++++++---- test/widgets/action_sheet_test.dart | 84 +++++++++- test/widgets/message_list_test.dart | 18 ++ 16 files changed, 267 insertions(+), 49 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 38ed544e9c..d7fd14303b 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1047,7 +1047,7 @@ "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." }, - "revealButtonLabel": "Reveal message for muted sender", + "revealButtonLabel": "Reveal message", "@revealButtonLabel": { "description": "Label for the button revealing hidden message from a muted sender in message list." }, diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 1d919c5a9d..2a0b5be2ed 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1085,10 +1085,6 @@ "@mutedSender": { "description": "Name for a muted user to display in message list." }, - "revealButtonLabel": "Odsłoń wiadomość od wyciszonego użytkownika", - "@revealButtonLabel": { - "description": "Label for the button revealing hidden message from a muted sender in message list." - }, "mutedUser": "Wyciszony użytkownik", "@mutedUser": { "description": "Name for a muted user to display all over the app." diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index acff65ee7f..20e302cc63 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1077,10 +1077,6 @@ "@mutedSender": { "description": "Name for a muted user to display in message list." }, - "revealButtonLabel": "Показать сообщение отключенного отправителя", - "@revealButtonLabel": { - "description": "Label for the button revealing hidden message from a muted sender in message list." - }, "mutedUser": "Отключенный пользователь", "@mutedUser": { "description": "Name for a muted user to display all over the app." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 8ce851694b..3887e381a2 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1566,7 +1566,7 @@ abstract class ZulipLocalizations { /// Label for the button revealing hidden message from a muted sender in message list. /// /// In en, this message translates to: - /// **'Reveal message for muted sender'** + /// **'Reveal message'** String get revealButtonLabel; /// Text to display in place of a muted user's name. diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 5187ff9ba1..a4e972abc4 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -855,7 +855,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index de99dd7130..f065d5f59c 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -855,7 +855,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index b03ad14633..eccff7ea5d 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -855,7 +855,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 8a9050df7d..69557352b5 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -855,7 +855,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 4249e4af4c..21e8f3e478 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -868,7 +868,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get noEarlierMessages => 'Brak historii'; @override - String get revealButtonLabel => 'Odsłoń wiadomość od wyciszonego użytkownika'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Wyciszony użytkownik'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 7213c05535..f4f25c7d20 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -872,7 +872,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get noEarlierMessages => 'Предшествующих сообщений нет'; @override - String get revealButtonLabel => 'Показать сообщение отключенного отправителя'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Отключенный пользователь'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 6414b2e01f..4558dcd872 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -857,7 +857,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index c23c5364b6..b15d029eb1 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -855,7 +855,7 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index a78ba323c7..d040ea8bcf 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -589,6 +589,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; + final isSenderMuted = store.isUserMuted(message.senderId); + final optionButtons = [ if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), @@ -597,6 +599,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) MarkAsUnreadButton(message: message, pageContext: pageContext), + if (isSenderMuted) + // The message must have been revealed in order to open this action sheet. + UnrevealMutedMessageButton(message: message, pageContext: pageContext), CopyMessageTextButton(message: message, pageContext: pageContext), CopyMessageLinkButton(message: message, pageContext: pageContext), ShareButton(message: message, pageContext: pageContext), @@ -904,6 +909,30 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { } } +class UnrevealMutedMessageButton extends MessageActionSheetMenuItemButton { + UnrevealMutedMessageButton({ + super.key, + required super.message, + required super.pageContext, + }); + + @override + IconData get icon => ZulipIcons.eye_off; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionHideMutedMessage; + } + + @override + void onPressed() { + // The message should have been revealed in order to reach this action sheet. + assert(MessageListPage.revealedMutedMessagesOf(pageContext) + .isMutedMessageRevealed(message.id)); + findMessageListPage().unrevealMutedMessage(message.id); + } +} + class CopyMessageTextButton extends MessageActionSheetMenuItemButton { CopyMessageTextButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index eb9d554103..f0ecbe10a1 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -16,6 +16,7 @@ import '../model/typing_status.dart'; import 'action_sheet.dart'; import 'actions.dart'; import 'app_bar.dart'; +import 'button.dart'; import 'color.dart'; import 'compose_box.dart'; import 'content.dart'; @@ -150,6 +151,14 @@ abstract class MessageListPageState { /// "Mark as unread from here" in the message action sheet. bool? get markReadOnScroll; set markReadOnScroll(bool? value); + + /// For a message from a muted sender, reveal the sender and content, + /// replacing the "Muted user" placeholder. + void revealMutedMessage(int messageId); + + /// For a message from a muted sender, hide the sender and content again + /// with the "Muted user" placeholder. + void unrevealMutedMessage(int messageId); } class MessageListPage extends StatefulWidget { @@ -166,6 +175,21 @@ class MessageListPage extends StatefulWidget { initNarrow: narrow, initAnchorMessageId: initAnchorMessageId)); } + /// The "revealed" state of a message from a muted sender. + /// + /// This is updated via [MessageListPageState.revealMutedMessage] + /// and [MessageListPageState.unrevealMutedMessage]. + /// + /// Uses the efficient [BuildContext.dependOnInheritedWidgetOfExactType], + /// so this is safe to call in a build method. + static RevealedMutedMessagesState revealedMutedMessagesOf(BuildContext context) { + final state = + context.dependOnInheritedWidgetOfExactType<_RevealedMutedMessagesProvider>() + ?.state; + assert(state != null, 'No MessageListPage ancestor'); + return state!; + } + /// The [MessageListPageState] above this context in the tree. /// /// Uses the inefficient [BuildContext.findAncestorStateOfType]; @@ -233,6 +257,18 @@ class _MessageListPageState extends State implements MessageLis }); } + final _revealedMutedMessages = RevealedMutedMessagesState(); + + @override + void revealMutedMessage(int messageId) { + _revealedMutedMessages._add(messageId); + } + + @override + void unrevealMutedMessage(int messageId) { + _revealedMutedMessages._remove(messageId); + } + @override void initState() { super.initState(); @@ -303,9 +339,7 @@ class _MessageListPageState extends State implements MessageLis initAnchor = useFirstUnread ? AnchorCode.firstUnread : AnchorCode.newest; } - // Insert a PageRoot here, to provide a context that can be used for - // MessageListPage.ancestorOf. - return PageRoot(child: Scaffold( + Widget result = Scaffold( appBar: ZulipAppBar( buildTitle: (willCenterTitle) => MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle), @@ -350,10 +384,45 @@ class _MessageListPageState extends State implements MessageLis if (ComposeBox.hasComposeBox(narrow)) ComposeBox(key: _composeBoxKey, narrow: narrow) ]); - }))); + })); + + // Insert a PageRoot here (under MessageListPage), + // to provide a context that can be used for MessageListPage.ancestorOf. + result = PageRoot(child: result); + + result = _RevealedMutedMessagesProvider(state: _revealedMutedMessages, + child: result); + + return result; } } +class RevealedMutedMessagesState extends ChangeNotifier { + final Set _revealedMessages = {}; + + bool isMutedMessageRevealed(int messageId) => + _revealedMessages.contains(messageId); + + void _add(int messageId) { + _revealedMessages.add(messageId); + notifyListeners(); + } + + void _remove(int messageId) { + _revealedMessages.remove(messageId); + notifyListeners(); + } +} + +class _RevealedMutedMessagesProvider extends InheritedNotifier { + const _RevealedMutedMessagesProvider({ + required RevealedMutedMessagesState state, + required super.child, + }) : super(notifier: state); + + RevealedMutedMessagesState get state => notifier!; +} + class _TopicListButton extends StatelessWidget { const _TopicListButton({required this.streamId}); @@ -1700,6 +1769,12 @@ class _SenderRow extends StatelessWidget { final sender = store.getUser(message.senderId); final time = _kMessageTimestampFormat .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); + + final showAsMuted = store.isUserMuted(message.senderId) + && message is Message // i.e., not an outbox message + && !MessageListPage.revealedMutedMessagesOf(context) + .isMutedMessageRevealed((message as Message).id); + return Padding( padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), child: Row( @@ -1708,7 +1783,7 @@ class _SenderRow extends StatelessWidget { children: [ Flexible( child: GestureDetector( - onTap: () => Navigator.push(context, + onTap: () => showAsMuted ? null : Navigator.push(context, ProfilePage.buildRoute(context: context, userId: message.senderId)), child: Row( @@ -1717,16 +1792,20 @@ class _SenderRow extends StatelessWidget { size: 32, borderRadius: 3, showPresence: false, + replaceIfMuted: showAsMuted, userId: message.senderId), const SizedBox(width: 8), Flexible( child: Text(message is Message - ? store.senderDisplayName(message as Message) + ? store.senderDisplayName(message as Message, + replaceIfMuted: showAsMuted) : store.userDisplayName(message.senderId), style: TextStyle( fontSize: 18, height: (22 / 18), - color: designVariables.title, + color: showAsMuted + ? designVariables.title.withFadedAlpha(0.5) + : designVariables.title, ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), if (sender?.isBot ?? false) ...[ @@ -1821,6 +1900,10 @@ class MessageWithPossibleSender extends StatelessWidget { || StarredMessagesNarrow() => true, }; + final showAsMuted = store.isUserMuted(message.senderId) + && !MessageListPage.revealedMutedMessagesOf(context) + .isMutedMessageRevealed(message.id); + return GestureDetector( behavior: HitTestBehavior.translucent, onTap: tapOpensConversation @@ -1830,7 +1913,9 @@ class MessageWithPossibleSender extends StatelessWidget { // TODO(#1655) "this view does not mark messages as read on scroll" initAnchorMessageId: message.id))) : null, - onLongPress: () => showMessageActionSheet(context: context, message: message), + onLongPress: showAsMuted + ? null // TODO write a test for this + : () => showMessageActionSheet(context: context, message: message), child: Padding( padding: const EdgeInsets.only(top: 4), child: Column(children: [ @@ -1841,28 +1926,40 @@ class MessageWithPossibleSender extends StatelessWidget { textBaseline: localizedTextBaseline(context), children: [ const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - content, - if ((message.reactions?.total ?? 0) > 0) - ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editMessageErrorStatus != null) - _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) - else if (editStateText != null) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing(context, - 0.05, baseFontSize: 12)))) - else - Padding(padding: const EdgeInsets.only(bottom: 4)) - ])), + Expanded(child: showAsMuted + ? Align( + alignment: AlignmentDirectional.topStart, + child: ZulipWebUiKitButton( + label: zulipLocalizations.revealButtonLabel, + icon: ZulipIcons.eye, + size: ZulipWebUiKitButtonSize.small, + intent: ZulipWebUiKitButtonIntent.neutral, + attention: ZulipWebUiKitButtonAttention.minimal, + onPressed: () { + MessageListPage.ancestorOf(context).revealMutedMessage(message.id); + })) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if ((message.reactions?.total ?? 0) > 0) + ReactionChipsList(messageId: message.id, reactions: message.reactions!), + if (editMessageErrorStatus != null) + _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) + else if (editStateText != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12)))) + else + Padding(padding: const EdgeInsets.only(bottom: 4)) + ])), SizedBox(width: 16, child: star), ]), diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index ebb6cb9b71..bee941abe0 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -41,6 +41,7 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; +import '../test_images.dart'; import '../test_share_plus.dart'; import 'compose_box_checks.dart'; import 'dialog_checks.dart'; @@ -53,10 +54,13 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + User? sender, + List? mutedUserIds, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, bool shouldSetServerEmojiData = true, bool useLegacyServerEmojiData = false, + Future Function()? beforeLongPress, }) async { addTearDown(testBinding.reset); assert(narrow.containsMessage(message)); @@ -70,10 +74,13 @@ Future setupToMessageActionSheet(WidgetTester tester, { store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([ eg.selfUser, - eg.user(userId: message.senderId), + sender ?? eg.user(userId: message.senderId), if (narrow is DmNarrow) ...narrow.otherRecipientIds.map((id) => eg.user(userId: id)), ]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } if (message is StreamMessage) { final stream = eg.stream(streamId: message.streamId); await store.addStream(stream); @@ -94,6 +101,8 @@ Future setupToMessageActionSheet(WidgetTester tester, { // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); + await beforeLongPress?.call(); + // Request the message action sheet. // // We use `warnIfMissed: false` to suppress warnings in cases where @@ -1335,6 +1344,79 @@ void main() { }); }); + group('UnrevealMutedMessageButton', () { + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message = eg.streamMessage(sender: user, + content: '

A message

', reactions: [eg.unicodeEmojiReaction]); + + final revealButtonFinder = find.widgetWithText(ZulipWebUiKitButton, + 'Reveal message'); + + final contentFinder = find.descendant( + of: find.byType(MessageContent), + matching: find.text('A message', findRichText: true)); + + testWidgets('not visible if message is from normal sender (not muted)', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user); + check(store.isUserMuted(user.userId)).isFalse(); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('visible if message is from muted sender and revealed', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + check(contentFinder).findsNothing(); + await tester.tap(revealButtonFinder); + await tester.pump(); + check(contentFinder).findsOne(); + }, + ); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('when pressed, unreveals the message', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + check(contentFinder).findsNothing(); + await tester.tap(revealButtonFinder); + await tester.pump(); + check(contentFinder).findsOne(); + }); + + await tester.ensureVisible(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.eye_off)); + await tester.pumpAndSettle(); + + check(contentFinder).findsNothing(); + check(revealButtonFinder).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('CopyMessageTextButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 623e2318e7..f1961fc809 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1709,10 +1709,15 @@ void main() { final senderNameFinder = find.widgetWithText(MessageWithPossibleSender, senderName); + final contentFinder = find.descendant( + of: find.byType(MessageContent), + matching: find.text('A message', findRichText: true)); + check(mutedLabelFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); check(senderNameFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); check(mutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); check(nonmutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + check(contentFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); } final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); @@ -1734,6 +1739,19 @@ void main() { checkMessage(message, expectIsMuted: false); debugNetworkImageHttpClientProvider = null; }); + + testWidgets('"Reveal message" button', (tester) async { + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester, + users: [user], mutedUserIds: [user.userId], messages: [message]); + checkMessage(message, expectIsMuted: true); + await tester.tap(find.text('Reveal message')); + await tester.pump(); + checkMessage(message, expectIsMuted: false); + + debugNetworkImageHttpClientProvider = null; + }); }); group('Opens conversation on tap?', () { From 3974ff1c0ba4e4a3a31af8c556f943bafae0e4f0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 1 Jul 2025 11:18:56 -0700 Subject: [PATCH 250/290] page [nfc]: Move no-content placeholder widget to page.dart, from home.dart We can reuse this for the empty message list. --- lib/widgets/home.dart | 34 ----------------------- lib/widgets/inbox.dart | 2 +- lib/widgets/page.dart | 35 ++++++++++++++++++++++++ lib/widgets/recent_dm_conversations.dart | 2 +- lib/widgets/subscription_list.dart | 2 +- 5 files changed, 38 insertions(+), 37 deletions(-) diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 9a0850e0b9..88d3bf5d2e 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -148,40 +148,6 @@ class _HomePageState extends State { } } -/// A "no content here" message, for the Inbox, Subscriptions, and DMs pages. -/// -/// This should go near the root of the "page body"'s widget subtree. -/// In particular, it handles the horizontal device insets. -/// (The vertical insets are handled externally, by the app bar and bottom nav.) -class PageBodyEmptyContentPlaceholder extends StatelessWidget { - const PageBodyEmptyContentPlaceholder({super.key, required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); - - return SafeArea( - minimum: EdgeInsets.symmetric(horizontal: 24), - child: Padding( - padding: EdgeInsets.only(top: 48, bottom: 16), - child: Align( - alignment: Alignment.topCenter, - // TODO leading and trailing elements, like in Figma (given as SVGs): - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev - child: Text( - textAlign: TextAlign.center, - style: TextStyle( - color: designVariables.labelSearchPrompt, - fontSize: 17, - height: 23 / 17, - ).merge(weightVariableTextStyle(context, wght: 500)), - message)))); - } -} - - const kTryAnotherAccountWaitPeriod = Duration(seconds: 5); class _LoadingPlaceholderPage extends StatefulWidget { diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 7f101a81ce..d00cabb9dc 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -6,9 +6,9 @@ import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'action_sheet.dart'; -import 'home.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'page.dart'; import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index a2c6fe52a1..d935e91d4d 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; /// An [InheritedWidget] for near the root of a page's widget subtree, /// providing its [BuildContext]. @@ -210,3 +212,36 @@ class LoadingPlaceholderPage extends StatelessWidget { ); } } + +/// A "no content here" message, for the Inbox, Subscriptions, and DMs pages. +/// +/// This should go near the root of the "page body"'s widget subtree. +/// In particular, it handles the horizontal device insets. +/// (The vertical insets are handled externally, by the app bar and bottom nav.) +class PageBodyEmptyContentPlaceholder extends StatelessWidget { + const PageBodyEmptyContentPlaceholder({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return SafeArea( + minimum: EdgeInsets.symmetric(horizontal: 24), + child: Padding( + padding: EdgeInsets.only(top: 48, bottom: 16), + child: Align( + alignment: Alignment.topCenter, + // TODO leading and trailing elements, like in Figma (given as SVGs): + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev + child: Text( + textAlign: TextAlign.center, + style: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 23 / 17, + ).merge(weightVariableTextStyle(context, wght: 500)), + message)))); + } +} diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 5758a39d76..97c53ac4b1 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -5,10 +5,10 @@ import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'content.dart'; -import 'home.dart'; import 'icons.dart'; import 'message_list.dart'; import 'new_dm_sheet.dart'; +import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 8a7bd9b9b5..ff3db94391 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -5,9 +5,9 @@ import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/unreads.dart'; import 'action_sheet.dart'; -import 'home.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; From 7bd509de8f4b83964dfb37a04d3eb0b8789e4dc6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 26 Jun 2025 20:17:21 -0700 Subject: [PATCH 251/290] msglist test [nfc]: Move a Finder helper outward for reuse --- test/widgets/message_list_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f1961fc809..0209d11275 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -129,6 +129,9 @@ void main() { return findScrollView(tester).controller; } + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + group('MessageListPage', () { testWidgets('ancestorOf finds page state from message', (tester) async { await setupMessageListPage(tester, @@ -1837,9 +1840,6 @@ void main() { final topicNarrow = eg.topicNarrow(stream.streamId, topic); const content = 'outbox message content'; - final contentInputFinder = find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller is ComposeContentController); - Finder outboxMessageFinder = find.widgetWithText( OutboxMessageWithPossibleSender, content, skipOffstage: true); From 8749817f7fd0f040fd8f03a966b3ee744e734a03 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 1 Jul 2025 16:56:57 -0700 Subject: [PATCH 252/290] msglist: Friendlier placeholder text when narrow has no messages Fixes #1555. For now, the text simply says "There are no messages here." We'll add per-narrow logic later, but this is an improvement over the current appearance which just says "No earlier messages." (Earlier than what?) To support being used in the message-list page (in addition to Inbox, etc.), the placeholder widget only needs small changes, it turns out. --- assets/l10n/app_en.arb | 4 +++ lib/generated/l10n/zulip_localizations.dart | 6 ++++ .../l10n/zulip_localizations_ar.dart | 3 ++ .../l10n/zulip_localizations_de.dart | 3 ++ .../l10n/zulip_localizations_en.dart | 3 ++ .../l10n/zulip_localizations_it.dart | 3 ++ .../l10n/zulip_localizations_ja.dart | 3 ++ .../l10n/zulip_localizations_nb.dart | 3 ++ .../l10n/zulip_localizations_pl.dart | 3 ++ .../l10n/zulip_localizations_ru.dart | 3 ++ .../l10n/zulip_localizations_sk.dart | 3 ++ .../l10n/zulip_localizations_sl.dart | 3 ++ .../l10n/zulip_localizations_uk.dart | 3 ++ .../l10n/zulip_localizations_zh.dart | 3 ++ lib/widgets/message_list.dart | 7 +++++ lib/widgets/page.dart | 16 ++++++---- test/widgets/message_list_test.dart | 30 +++++++++++++++++++ 17 files changed, 93 insertions(+), 6 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index d7fd14303b..9b6c5f6532 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -530,6 +530,10 @@ "others": {"type": "String", "example": "Alice, Bob"} } }, + "emptyMessageList": "There are no messages here.", + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, "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 3887e381a2..85e6b1bbe9 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -845,6 +845,12 @@ abstract class ZulipLocalizations { /// **'DMs with {others}'** String dmsWithOthersPageTitle(String others); + /// Placeholder for some message-list pages when there are no messages. + /// + /// In en, this message translates to: + /// **'There are no messages here.'** + String get emptyMessageList; + /// Message list recipient header for a DM group that only includes yourself. /// /// 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 a4e972abc4..29eeaa84b7 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -434,6 +434,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 8ca5d081e3..a54262e964 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -449,6 +449,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { return 'DNs mit $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Nachrichten mit dir selbst'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index f065d5f59c..9cca30a3e1 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -434,6 +434,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 8fc5df768b..451c959345 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -445,6 +445,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { return 'MD con $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Messaggi con te stesso'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index eccff7ea5d..ccdfad4001 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -434,6 +434,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 69557352b5..bd708f0b7c 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -434,6 +434,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 21e8f3e478..e744825644 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -443,6 +443,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'DM z $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Zapiski na własne konto'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index f4f25c7d20..a980357a8e 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -443,6 +443,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'ЛС с $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Сообщения с собой'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 4558dcd872..afb6d05654 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -434,6 +434,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index e6f4275f77..2cb377a57b 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -455,6 +455,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { return 'Neposredna sporočila z $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Sporočila sebi'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 98ba4b11e1..ab35988c35 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -445,6 +445,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Особисті повідомлення з $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Повідомлення з собою'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index b15d029eb1..58330028ad 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -434,6 +434,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { return 'DMs with $others'; } + @override + String get emptyMessageList => 'There are no messages here.'; + @override String get messageListGroupYouWithYourself => 'Messages with yourself'; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index f0ecbe10a1..b14a9fe5ce 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -859,8 +859,15 @@ class _MessageListState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + if (!model.fetched) return const Center(child: CircularProgressIndicator()); + if (model.items.isEmpty && model.haveNewest && model.haveOldest) { + return PageBodyEmptyContentPlaceholder( + message: zulipLocalizations.emptyMessageList); + } + // Pad the left and right insets, for small devices in landscape. return SafeArea( // Don't let this be the place we pad the bottom inset. When there's diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index d935e91d4d..35bdf34923 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -213,11 +213,15 @@ class LoadingPlaceholderPage extends StatelessWidget { } } -/// A "no content here" message, for the Inbox, Subscriptions, and DMs pages. +/// A "no content here" message for when a page has no content to show. /// -/// This should go near the root of the "page body"'s widget subtree. -/// In particular, it handles the horizontal device insets. -/// (The vertical insets are handled externally, by the app bar and bottom nav.) +/// Suitable for the inbox, the message-list page, etc. +/// +/// This handles the horizontal device insets +/// and the bottom inset when needed (in a message list with no compose box). +/// The top inset is handled externally by the app bar. +// TODO(#311) If the message list gets a bottom nav, the bottom inset will +// always be handled externally too; simplify implementation and dartdoc. class PageBodyEmptyContentPlaceholder extends StatelessWidget { const PageBodyEmptyContentPlaceholder({super.key, required this.message}); @@ -228,9 +232,9 @@ class PageBodyEmptyContentPlaceholder extends StatelessWidget { final designVariables = DesignVariables.of(context); return SafeArea( - minimum: EdgeInsets.symmetric(horizontal: 24), + minimum: EdgeInsets.fromLTRB(24, 0, 24, 16), child: Padding( - padding: EdgeInsets.only(top: 48, bottom: 16), + padding: EdgeInsets.only(top: 48), child: Align( alignment: Alignment.topCenter, // TODO leading and trailing elements, like in Figma (given as SVGs): diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 0209d11275..dbcdf5ff1d 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -349,6 +349,36 @@ void main() { }); }); + group('no-messages placeholder', () { + final findPlaceholder = find.byType(PageBodyEmptyContentPlaceholder); + + testWidgets('Combined feed', (tester) async { + await setupMessageListPage(tester, narrow: CombinedFeedNarrow(), messages: []); + check( + find.descendant( + of: findPlaceholder, + matching: find.textContaining('There are no messages here.')), + ).findsOne(); + }); + + testWidgets('when `messages` empty but `outboxMessages` not empty, show outboxes, not placeholder', (tester) async { + final channel = eg.stream(); + await setupMessageListPage(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: []); + check(findPlaceholder).findsOne(); + + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await tester.enterText(contentInputFinder, 'asdfjkl;'); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(kLocalEchoDebounceDuration); + + check(findPlaceholder).findsNothing(); + check(find.text('asdfjkl;')).findsOne(); + }); + }); + group('presents message content appropriately', () { testWidgets('content not asked to consume insets (including bottom), even without compose box', (tester) async { // Regression test for: https://github.com/zulip/zulip-flutter/issues/736 From f57ea718b89687a899d1d5a0f2160cc778ab4743 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 24 Apr 2025 11:37:46 +0530 Subject: [PATCH 253/290] content test [nfc]: Use const for math block tests --- test/model/content_test.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 5ab60c8e7e..6a0bc6ebe7 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -529,7 +529,7 @@ class ContentExample { ]), ])); - static final mathBlock = ContentExample( + static const mathBlock = ContentExample( 'math block', "```math\n\\lambda\n```", expectedText: r'\lambda', @@ -549,7 +549,7 @@ class ContentExample { ]), ])]); - static final mathBlocksMultipleInParagraph = ContentExample( + 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 @@ -586,7 +586,7 @@ class ContentExample { ]), ]); - static final mathBlockInQuote = ContentExample( + static const mathBlockInQuote = ContentExample( 'math block in quote', // There's sometimes a quirky extra `
\n` at the end of the `

` that // encloses the math block. In particular this happens when the math block @@ -614,7 +614,7 @@ class ContentExample { ]), ])]); - static final mathBlocksMultipleInQuote = ContentExample( + 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 @@ -654,7 +654,7 @@ class ContentExample { ]), ])]); - static final mathBlockBetweenImages = ContentExample( + 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', @@ -702,7 +702,7 @@ class ContentExample { // The font sizes can be compared using the katex.css generated // from katex.scss : // https://unpkg.com/katex@0.16.21/dist/katex.css - static final mathBlockKatexSizing = ContentExample( + static const mathBlockKatexSizing = ContentExample( 'math block; KaTeX different sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2155476 '```math\n\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0\n```', @@ -779,7 +779,7 @@ class ContentExample { ]), ]); - static final mathBlockKatexNestedSizing = ContentExample( + static const mathBlockKatexNestedSizing = ContentExample( 'math block; KaTeX nested sizing', '```math\n\\tiny {1 \\Huge 2}\n```', '

' @@ -821,7 +821,7 @@ class ContentExample { ]), ]); - static final mathBlockKatexDelimSizing = ContentExample( + static const mathBlockKatexDelimSizing = ContentExample( 'math block; KaTeX delimiter sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2147135 '```math\n⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊\n```', From d303cc081cc114ee0333effa8a2a331522778445 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 29 May 2025 11:00:43 -0700 Subject: [PATCH 254/290] content test [nfc]: Enable skips in testParseExample and testParse --- test/model/content_test.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 6a0bc6ebe7..e8c22298ca 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1642,15 +1642,18 @@ UnimplementedInlineContentNode inlineUnimplemented(String html) { return UnimplementedInlineContentNode(htmlNode: fragment.nodes.single); } -void testParse(String name, String html, List nodes) { +void testParse(String name, String html, List nodes, { + Object? skip, +}) { test(name, () { check(parseContent(html)) .equalsNode(ZulipContent(nodes: nodes)); - }); + }, skip: skip); } -void testParseExample(ContentExample example) { - testParse('parse ${example.description}', example.html, example.expectedNodes); +void testParseExample(ContentExample example, {Object? skip}) { + testParse('parse ${example.description}', example.html, example.expectedNodes, + skip: skip); } void main() async { @@ -2034,7 +2037,7 @@ void main() async { r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*ContentExample\s*(?:\.\s*inline\s*)?\(', ).allMatches(source).map((m) => m.group(1)); final testedExamples = RegExp(multiLine: true, - r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)\);', + r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)(?:,\s*skip:\s*true)?\s*\);', ).allMatches(source).map((m) => m.group(1)); check(testedExamples).unorderedEquals(declaredExamples); }, skip: Platform.isWindows, // [intended] purely analyzes source, so From 71b16af0bd1666171fc2b313696dfb0960ea83cf Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 8 May 2025 21:04:10 +0530 Subject: [PATCH 255/290] content [nfc]: Inline _logError in _KatexParser._parseSpan This will prevent string interpolation being evaluated during release build. Especially useful in later commit where it becomes more expensive. --- lib/model/katex.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 709f91b4b2..7c4b2deb1b 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -109,11 +109,6 @@ class _KatexParser { bool get hasError => _hasError; bool _hasError = false; - void _logError(String message) { - assert(debugLog(message)); - _hasError = true; - } - List parseKatexHtml(dom.Element element) { assert(element.localName == 'span'); assert(element.className == 'katex-html'); @@ -334,7 +329,8 @@ class _KatexParser { break; default: - _logError('KaTeX: Unsupported CSS class: $spanClass'); + assert(debugLog('KaTeX: Unsupported CSS class: $spanClass')); + _hasError = true; } } final styles = KatexSpanStyles( From b812cde1a3bf2ba1422d95635c51aefc00a55ff8 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 8 May 2025 21:34:09 +0530 Subject: [PATCH 256/290] content [nfc]: Refactor _KatexParser._parseChildSpans to take list of nodes --- lib/model/katex.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 7c4b2deb1b..f6375f907e 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -112,11 +112,11 @@ class _KatexParser { List parseKatexHtml(dom.Element element) { assert(element.localName == 'span'); assert(element.className == 'katex-html'); - return _parseChildSpans(element); + return _parseChildSpans(element.nodes); } - List _parseChildSpans(dom.Element element) { - return List.unmodifiable(element.nodes.map((node) { + List _parseChildSpans(List nodes) { + return List.unmodifiable(nodes.map((node) { if (node case dom.Element(localName: 'span')) { return _parseSpan(node); } else { @@ -346,7 +346,7 @@ class _KatexParser { if (element.nodes case [dom.Text(:final data)]) { text = data; } else { - spans = _parseChildSpans(element); + spans = _parseChildSpans(element.nodes); } if (text == null && spans == null) throw KatexHtmlParseError(); From e7053c25265de0d66baab49e723279c08a91a7ba Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 27 May 2025 23:02:56 +0530 Subject: [PATCH 257/290] content: Populate `debugHtmlNode` for KatexNode --- lib/model/katex.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index f6375f907e..4c7ddf7b64 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -131,6 +131,8 @@ class _KatexParser { KatexNode _parseSpan(dom.Element element) { // TODO maybe check if the sequence of ancestors matter for spans. + final debugHtmlNode = kDebugMode ? element : null; + // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. // @@ -353,7 +355,8 @@ class _KatexParser { return KatexNode( styles: styles, text: text, - nodes: spans); + nodes: spans, + debugHtmlNode: debugHtmlNode); } } From d79881067d532e92617b10e36778af08328f07e1 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 24 Apr 2025 13:21:18 +0530 Subject: [PATCH 258/290] content [nfc]: Reintroduce KatexNode as a base sealed class And rename previous type to KatexSpanNode, also while making it a subtype of KatexNode. --- lib/model/content.dart | 8 ++- lib/model/katex.dart | 2 +- lib/widgets/content.dart | 6 +- test/model/content_test.dart | 104 ++++++++++++++++----------------- test/widgets/content_test.dart | 12 ++-- 5 files changed, 70 insertions(+), 62 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 59f7b41aad..768031ae9a 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -369,8 +369,12 @@ abstract class MathNode extends ContentNode { } } -class KatexNode extends ContentNode { - const KatexNode({ +sealed class KatexNode extends ContentNode { + const KatexNode({super.debugHtmlNode}); +} + +class KatexSpanNode extends KatexNode { + const KatexSpanNode({ required this.styles, required this.text, required this.nodes, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 4c7ddf7b64..1fe747f210 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -352,7 +352,7 @@ class _KatexParser { } if (text == null && spans == null) throw KatexHtmlParseError(); - return KatexNode( + return KatexSpanNode( styles: styles, text: text, nodes: spans, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 2966b4dc46..a6f7835d0c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -872,7 +872,9 @@ class _KatexNodeList extends StatelessWidget { return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _KatexSpan(e)); + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + }); })))); } } @@ -880,7 +882,7 @@ class _KatexNodeList extends StatelessWidget { class _KatexSpan extends StatelessWidget { const _KatexSpan(this.node); - final KatexNode node; + final KatexSpanNode node; @override Widget build(BuildContext context) { diff --git a/test/model/content_test.dart b/test/model/content_test.dart index e8c22298ca..baea1a8109 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -518,9 +518,9 @@ class ContentExample { ' \\lambda ' '

', MathInlineNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -538,9 +538,9 @@ class ContentExample { '\\lambda' '

', [MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -563,9 +563,9 @@ class ContentExample { 'b' '

', [ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -574,9 +574,9 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -602,9 +602,9 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -631,9 +631,9 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -642,9 +642,9 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -680,9 +680,9 @@ class ContentExample { originalHeight: null), ]), MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(),text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(),text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -727,51 +727,51 @@ class ContentExample { MathBlockNode( texSource: "\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0", nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 text: '1', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 text: '2', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 text: '3', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 text: '4', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 text: '5', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 text: '6', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 text: '7', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 text: '8', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 text: '9', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 text: '0', nodes: null), @@ -796,23 +796,23 @@ class ContentExample { MathBlockNode( texSource: '\\tiny {1 \\Huge 2}', nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: '1', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 text: '2', nodes: null), @@ -841,50 +841,50 @@ class ContentExample { MathBlockNode( texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: '⟨', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), text: '(', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), text: '[', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), text: '⌈', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), text: '⌊', nodes: null), diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index b5150a54ee..7e1309478f 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -596,9 +596,10 @@ void main() { await prepareContent(tester, plainContent(content.html)); final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; + final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; final nodes = baseNode.nodes!.skip(1); // Skip .strut node. - for (final katexNode in nodes) { + for (var katexNode in nodes) { + katexNode = katexNode as KatexSpanNode; final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; checkKatexText(tester, katexNode.text!, fontFamily: 'KaTeX_Main', @@ -639,12 +640,12 @@ void main() { await prepareContent(tester, plainContent(content.html)); final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; + final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; var nodes = baseNode.nodes!.skip(1); // Skip .strut node. final fontSize = kBaseKatexTextStyle.fontSize!; - final firstNode = nodes.first; + final firstNode = nodes.first as KatexSpanNode; checkKatexText(tester, firstNode.text!, fontFamily: 'KaTeX_Main', fontSize: fontSize, @@ -652,7 +653,8 @@ void main() { nodes = nodes.skip(1); for (var katexNode in nodes) { - katexNode = katexNode.nodes!.single; // Skip empty .mord parent. + katexNode = katexNode as KatexSpanNode; + katexNode = katexNode.nodes!.single as KatexSpanNode; // Skip empty .mord parent. final fontFamily = katexNode.styles.fontFamily!; checkKatexText(tester, katexNode.text!, fontFamily: fontFamily, From 10620d7eb0b35ee5615a40bdf8b6738ec5b0788c Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:36:45 +0530 Subject: [PATCH 259/290] content: Ignore more KaTeX classes that don't have CSS definition --- lib/model/katex.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 1fe747f210..dd69cc181a 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -326,6 +326,12 @@ class _KatexParser { case 'mord': case 'mopen': + case 'mtight': + case 'text': + case 'mrel': + case 'mop': + case 'mclose': + case 'minner': // Ignore these classes because they don't have a CSS definition // in katex.scss, but we encounter them in the generated HTML. break; From e97140200725a8f8ef89d41151ff327b854858d9 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:42:55 +0530 Subject: [PATCH 260/290] content: Handle 'mspace' and 'msupsub' KaTeX CSS classes --- lib/model/katex.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index dd69cc181a..8d9a646f08 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -272,6 +272,21 @@ class _KatexParser { fontStyle = KatexSpanFontStyle.normal; // TODO handle skipped class declarations between .mainrm and + // .mspace . + + case 'mspace': + // .mspace { ... } + // Do nothing, it has properties that don't need special handling. + break; + + // TODO handle skipped class declarations between .mspace and + // .msupsub . + + case 'msupsub': + // .msupsub { text-align: left; } + textAlign = KatexSpanTextAlign.left; + + // TODO handle skipped class declarations between .msupsub and // .sizing . case 'sizing': From 44305ef37e2f119104f9506b1ccb56ba9e17eb92 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 1 Jul 2025 18:31:39 -0700 Subject: [PATCH 261/290] content [nfc]: Explain why KaTeX .mspace requires no action --- lib/model/katex.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 8d9a646f08..2646603873 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -275,8 +275,13 @@ class _KatexParser { // .mspace . case 'mspace': - // .mspace { ... } - // Do nothing, it has properties that don't need special handling. + // .mspace { display: inline-block; } + // A .mspace span's children are always either empty, + // a no-break space " " (== "\xa0"), + // or one span.mtight containing a no-break space. + // TODO enforce that constraint on .mspace spans in parsing + // So `display: inline-block` has no effect compared to + // the initial `display: inline`. break; // TODO handle skipped class declarations between .mspace and From 86dbcf886d3a18318d5a5ae2a4047fc31c2d3c6a Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 1 Apr 2025 18:32:25 +0530 Subject: [PATCH 262/290] content: Support parsing and handling inline styles for KaTeX content --- lib/model/katex.dart | 82 +++++++++++++++++++++++++++++++++- lib/widgets/content.dart | 7 ++- test/model/content_test.dart | 27 ++++++----- test/widgets/content_test.dart | 4 +- 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 2646603873..fa724d1267 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,4 +1,7 @@ +import 'package:csslib/parser.dart' as css_parser; +import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:html/dom.dart' as dom; import '../log.dart'; @@ -133,6 +136,8 @@ class _KatexParser { final debugHtmlNode = kDebugMode ? element : null; + final inlineStyles = _parseSpanInlineStyles(element); + // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. // @@ -379,11 +384,62 @@ class _KatexParser { if (text == null && spans == null) throw KatexHtmlParseError(); return KatexSpanNode( - styles: styles, + styles: inlineStyles != null + ? styles.merge(inlineStyles) + : styles, text: text, nodes: spans, debugHtmlNode: debugHtmlNode); } + + KatexSpanStyles? _parseSpanInlineStyles(dom.Element element) { + if (element.attributes case {'style': final styleStr}) { + // `package:csslib` doesn't seem to have a way to parse inline styles: + // https://github.com/dart-lang/tools/issues/1173 + // So, work around that by wrapping it in a universal declaration. + final stylesheet = css_parser.parse('*{$styleStr}'); + if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { + double? heightEm; + + for (final declaration in rule.declarationGroup.declarations) { + if (declaration case css_visitor.Declaration( + :final property, + expression: css_visitor.Expressions( + expressions: [css_visitor.Expression() && final expression]), + )) { + switch (property) { + case 'height': + heightEm = _getEm(expression); + if (heightEm != null) continue; + } + + // TODO handle more CSS properties + assert(debugLog('KaTeX: Unsupported CSS expression:' + ' ${expression.toDebugString()}')); + _hasError = true; + } else { + throw KatexHtmlParseError(); + } + } + + return KatexSpanStyles( + heightEm: heightEm, + ); + } else { + throw KatexHtmlParseError(); + } + } + return null; + } + + /// Returns the CSS `em` unit value if the given [expression] is actually an + /// `em` unit expression, else returns null. + double? _getEm(css_visitor.Expression expression) { + if (expression is css_visitor.EmTerm && expression.value is num) { + return (expression.value as num).toDouble(); + } + return null; + } } enum KatexSpanFontWeight { @@ -403,6 +459,8 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { + final double? heightEm; + final String? fontFamily; final double? fontSizeEm; final KatexSpanFontWeight? fontWeight; @@ -410,6 +468,7 @@ class KatexSpanStyles { final KatexSpanTextAlign? textAlign; const KatexSpanStyles({ + this.heightEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -420,6 +479,7 @@ class KatexSpanStyles { @override int get hashCode => Object.hash( 'KatexSpanStyles', + heightEm, fontFamily, fontSizeEm, fontWeight, @@ -430,6 +490,7 @@ class KatexSpanStyles { @override bool operator ==(Object other) { return other is KatexSpanStyles && + other.heightEm == heightEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -440,6 +501,7 @@ class KatexSpanStyles { @override String toString() { final args = []; + if (heightEm != null) args.add('heightEm: $heightEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -447,6 +509,24 @@ class KatexSpanStyles { if (textAlign != null) args.add('textAlign: $textAlign'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } + + /// Creates a new [KatexSpanStyles] with current and [other]'s styles merged. + /// + /// The styles in [other] take precedence and any missing styles in [other] + /// are filled in with current styles, if present. + /// + /// This similar to the behaviour of [TextStyle.merge], if the given style + /// had `inherit` set to true. + KatexSpanStyles merge(KatexSpanStyles other) { + return KatexSpanStyles( + heightEm: other.heightEm ?? heightEm, + fontFamily: other.fontFamily ?? fontFamily, + fontSizeEm: other.fontSizeEm ?? fontSizeEm, + fontStyle: other.fontStyle ?? fontStyle, + fontWeight: other.fontWeight ?? fontWeight, + textAlign: other.textAlign ?? textAlign, + ); + } } class KatexHtmlParseError extends Error { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index a6f7835d0c..8b3512be33 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -945,7 +945,12 @@ class _KatexSpan extends StatelessWidget { textAlign: textAlign, child: widget); } - return widget; + + return SizedBox( + height: styles.heightEm != null + ? styles.heightEm! * em + : null, + child: widget); } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index baea1a8109..c90ac54b33 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -519,7 +519,7 @@ class ContentExample { '

', MathInlineNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -539,7 +539,7 @@ class ContentExample { '

', [MathBlockNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -564,7 +564,7 @@ class ContentExample { '

', [ MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -575,7 +575,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'b', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -603,7 +603,7 @@ class ContentExample { [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -632,7 +632,7 @@ class ContentExample { [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -643,7 +643,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'b', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -681,7 +681,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(),text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306),text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -732,7 +732,7 @@ class ContentExample { text: null, nodes: [ KatexSpanNode( - styles: KatexSpanStyles(), + styles: KatexSpanStyles(heightEm: 1.6034), text: null, nodes: []), KatexSpanNode( @@ -801,7 +801,7 @@ class ContentExample { text: null, nodes: [ KatexSpanNode( - styles: KatexSpanStyles(), + styles: KatexSpanStyles(heightEm: 1.6034), text: null, nodes: []), KatexSpanNode( @@ -846,7 +846,7 @@ class ContentExample { text: null, nodes: [ KatexSpanNode( - styles: KatexSpanStyles(), + styles: KatexSpanStyles(heightEm: 3.0), text: null, nodes: []), KatexSpanNode( @@ -1963,7 +1963,10 @@ void main() async { testParseExample(ContentExample.mathBlockBetweenImages); testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); - testParseExample(ContentExample.mathBlockKatexDelimSizing); + // TODO: Re-enable this test after adding support for parsing + // `vertical-align` in inline styles. Currently it fails + // because `strut` span has `vertical-align`. + testParseExample(ContentExample.mathBlockKatexDelimSizing, skip: true); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 7e1309478f..754410fddc 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -661,7 +661,9 @@ void main() { fontSize: fontSize, fontHeight: kBaseKatexTextStyle.height!); } - }); + }, skip: true); // TODO: Re-enable this test after adding support for parsing + // `vertical-align` in inline styles. Currently it fails + // because `strut` span has `vertical-align`. }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], From f003f58edf6aaec725d89932ad4580172839b13a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 1 Jul 2025 20:57:27 -0700 Subject: [PATCH 263/290] content: Correctly apply font-size to interpret "em" on the same KaTeX span In CSS, the `em` unit is the font-size of the element, except when defining font-size itself (in which case it's the font-size inherited from the parent). See MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/length#em So when the same HTML span has a declared value for font-size, and also a declared value in units of `em` for some other property, the declared font-size needs to be applied in order to correctly interpret the meaning of `em` in the other property's value. It's possible this never comes up in practice -- that KaTeX never ends up giving us a span that gets both a font-size and a height. If it were hard to correctly handle this, we might try to verify that's the case and then rely on it (with an appropriate check to throw an error if that assumption failed). But the fix is easy, so just fix it. --- lib/widgets/content.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 8b3512be33..b49fdb4d9c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -948,7 +948,7 @@ class _KatexSpan extends StatelessWidget { return SizedBox( height: styles.heightEm != null - ? styles.heightEm! * em + ? styles.heightEm! * (fontSize ?? em) : null, child: widget); } From 3438f0890368c0c19f35eed79eeac2a9ac7b703d Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 17 Jun 2025 20:39:52 +0530 Subject: [PATCH 264/290] content: Ignore KaTeX classes that don't have CSS definition --- lib/model/katex.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index fa724d1267..e6851dba7f 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -357,6 +357,11 @@ class _KatexParser { case 'mop': case 'mclose': case 'minner': + case 'mbin': + case 'mpunct': + case 'nobreak': + case 'allowbreak': + case 'mathdefault': // Ignore these classes because they don't have a CSS definition // in katex.scss, but we encounter them in the generated HTML. break; From 04989164bfed4cf203997a5331db37f276463e21 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 1 Jul 2025 21:33:25 -0700 Subject: [PATCH 265/290] content [nfc]: Explain why some KaTeX CSS classes are unused in its CSS --- lib/model/katex.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index e6851dba7f..64c5eea82b 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -364,6 +364,12 @@ class _KatexParser { case 'mathdefault': // Ignore these classes because they don't have a CSS definition // in katex.scss, but we encounter them in the generated HTML. + // (Why are they there if they're not used? The story seems to be: + // they were used in KaTeX's CSS in the past, before 2020 or so; and + // they're still used internally by KaTeX in producing the HTML. + // https://github.com/KaTeX/KaTeX/issues/2194#issuecomment-584703052 + // https://github.com/KaTeX/KaTeX/issues/3344 + // ) break; default: From 5e686f0ab91cba1154c40f0f7830d400cd6562ce Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 17 Jun 2025 19:00:10 +0530 Subject: [PATCH 266/290] content [nfc]: Make MathNode a sealed class --- lib/model/content.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 768031ae9a..7a670ae5b1 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -341,7 +341,7 @@ class CodeBlockSpanNode extends ContentNode { } } -abstract class MathNode extends ContentNode { +sealed class MathNode extends ContentNode { const MathNode({ super.debugHtmlNode, required this.texSource, From 88ebff29c2ed7690c05c2514c68e74a8fd0e7858 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 17 Jun 2025 19:26:55 +0530 Subject: [PATCH 267/290] content [nfc]: Make `KatexHtmlParseError` private --- lib/model/katex.dart | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 64c5eea82b..b5782e9485 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -93,7 +93,7 @@ MathParserResult? parseMath(dom.Element element, { required bool block }) { final parser = _KatexParser(); try { nodes = parser.parseKatexHtml(katexHtmlElement); - } on KatexHtmlParseError catch (e, st) { + } on _KatexHtmlParseError catch (e, st) { assert(debugLog('$e\n$st')); } @@ -123,7 +123,7 @@ class _KatexParser { if (node case dom.Element(localName: 'span')) { return _parseSpan(node); } else { - throw KatexHtmlParseError(); + throw _KatexHtmlParseError(); } })); } @@ -303,14 +303,14 @@ class _KatexParser { case 'fontsize-ensurer': // .sizing, // .fontsize-ensurer { ... } - if (index + 2 > spanClasses.length) throw KatexHtmlParseError(); + if (index + 2 > spanClasses.length) throw _KatexHtmlParseError(); final resetSizeClass = spanClasses[index++]; final sizeClass = spanClasses[index++]; final resetSizeClassSuffix = _resetSizeClassRegExp.firstMatch(resetSizeClass)?.group(1); - if (resetSizeClassSuffix == null) throw KatexHtmlParseError(); + if (resetSizeClassSuffix == null) throw _KatexHtmlParseError(); final sizeClassSuffix = _sizeClassRegExp.firstMatch(sizeClass)?.group(1); - if (sizeClassSuffix == null) throw KatexHtmlParseError(); + if (sizeClassSuffix == null) throw _KatexHtmlParseError(); const sizes = [0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.2, 1.44, 1.728, 2.074, 2.488]; @@ -318,13 +318,13 @@ class _KatexParser { final sizeIdx = int.parse(sizeClassSuffix, radix: 10); // These indexes start at 1. - if (resetSizeIdx > sizes.length) throw KatexHtmlParseError(); - if (sizeIdx > sizes.length) throw KatexHtmlParseError(); + if (resetSizeIdx > sizes.length) throw _KatexHtmlParseError(); + if (sizeIdx > sizes.length) throw _KatexHtmlParseError(); fontSizeEm = sizes[sizeIdx - 1] / sizes[resetSizeIdx - 1]; case 'delimsizing': // .delimsizing { ... } - if (index + 1 > spanClasses.length) throw KatexHtmlParseError(); + if (index + 1 > spanClasses.length) throw _KatexHtmlParseError(); fontFamily = switch (spanClasses[index++]) { 'size1' => 'KaTeX_Size1', 'size2' => 'KaTeX_Size2', @@ -332,19 +332,19 @@ class _KatexParser { 'size4' => 'KaTeX_Size4', 'mult' => // TODO handle nested spans with `.delim-size{1,4}` class. - throw KatexHtmlParseError(), - _ => throw KatexHtmlParseError(), + throw _KatexHtmlParseError(), + _ => throw _KatexHtmlParseError(), }; // TODO handle .nulldelimiter and .delimcenter . case 'op-symbol': // .op-symbol { ... } - if (index + 1 > spanClasses.length) throw KatexHtmlParseError(); + if (index + 1 > spanClasses.length) throw _KatexHtmlParseError(); fontFamily = switch (spanClasses[index++]) { 'small-op' => 'KaTeX_Size1', 'large-op' => 'KaTeX_Size2', - _ => throw KatexHtmlParseError(), + _ => throw _KatexHtmlParseError(), }; // TODO handle more classes from katex.scss @@ -392,7 +392,7 @@ class _KatexParser { } else { spans = _parseChildSpans(element.nodes); } - if (text == null && spans == null) throw KatexHtmlParseError(); + if (text == null && spans == null) throw _KatexHtmlParseError(); return KatexSpanNode( styles: inlineStyles != null @@ -429,7 +429,7 @@ class _KatexParser { ' ${expression.toDebugString()}')); _hasError = true; } else { - throw KatexHtmlParseError(); + throw _KatexHtmlParseError(); } } @@ -437,7 +437,7 @@ class _KatexParser { heightEm: heightEm, ); } else { - throw KatexHtmlParseError(); + throw _KatexHtmlParseError(); } } return null; @@ -540,9 +540,10 @@ class KatexSpanStyles { } } -class KatexHtmlParseError extends Error { +class _KatexHtmlParseError extends Error { final String? message; - KatexHtmlParseError([this.message]); + + _KatexHtmlParseError([this.message]); @override String toString() { From b11f758c3a6c584e5b75edaed70ee63175284d02 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 17 Jun 2025 19:42:44 +0530 Subject: [PATCH 268/290] content: Allow KaTeX parser to report failure reasons --- lib/model/content.dart | 21 +++++++++++++--- lib/model/katex.dart | 57 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 7a670ae5b1..e4273f1b3a 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -346,6 +346,8 @@ sealed class MathNode extends ContentNode { super.debugHtmlNode, required this.texSource, required this.nodes, + this.debugHardFailReason, + this.debugSoftFailReason, }); final String texSource; @@ -357,6 +359,9 @@ sealed class MathNode extends ContentNode { /// fallback instead. final List? nodes; + final KatexParserHardFailReason? debugHardFailReason; + final KatexParserSoftFailReason? debugSoftFailReason; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -411,6 +416,8 @@ class MathBlockNode extends MathNode implements BlockContentNode { super.debugHtmlNode, required super.texSource, required super.nodes, + super.debugHardFailReason, + super.debugSoftFailReason, }); } @@ -880,6 +887,8 @@ class MathInlineNode extends MathNode implements InlineContentNode { super.debugHtmlNode, required super.texSource, required super.nodes, + super.debugHardFailReason, + super.debugSoftFailReason, }); } @@ -921,7 +930,9 @@ class _ZulipInlineContentParser { return MathInlineNode( texSource: parsed.texSource, nodes: parsed.nodes, - debugHtmlNode: debugHtmlNode); + debugHtmlNode: debugHtmlNode, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null); } UserMentionNode? parseUserMention(dom.Element element) { @@ -1628,7 +1639,9 @@ class _ZulipContentParser { result.add(MathBlockNode( texSource: parsed.texSource, nodes: parsed.nodes, - debugHtmlNode: kDebugMode ? firstChild : null)); + debugHtmlNode: kDebugMode ? firstChild : null, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null)); } else { result.add(UnimplementedBlockContentNode(htmlNode: firstChild)); } @@ -1664,7 +1677,9 @@ class _ZulipContentParser { result.add(MathBlockNode( texSource: parsed.texSource, nodes: parsed.nodes, - debugHtmlNode: debugHtmlNode)); + debugHtmlNode: debugHtmlNode, + debugHardFailReason: kDebugMode ? parsed.hardFailReason : null, + debugSoftFailReason: kDebugMode ? parsed.softFailReason : null)); continue; } } diff --git a/lib/model/katex.dart b/lib/model/katex.dart index b5782e9485..922546c676 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -9,10 +9,40 @@ import 'binding.dart'; import 'content.dart'; import 'settings.dart'; +/// The failure reason in case the KaTeX parser encountered a +/// `_KatexHtmlParseError` exception. +/// +/// Generally this means that parser encountered an unexpected HTML structure, +/// an unsupported HTML node, or an unexpected inline CSS style or CSS class on +/// a specific node. +class KatexParserHardFailReason { + const KatexParserHardFailReason({ + required this.error, + required this.stackTrace, + }); + + final String error; + final StackTrace stackTrace; +} + +/// The failure reason in case the KaTeX parser found an unsupported +/// CSS class or unsupported inline CSS style property. +class KatexParserSoftFailReason { + const KatexParserSoftFailReason({ + this.unsupportedCssClasses = const [], + this.unsupportedInlineCssProperties = const [], + }); + + final List unsupportedCssClasses; + final List unsupportedInlineCssProperties; +} + class MathParserResult { const MathParserResult({ required this.texSource, required this.nodes, + this.hardFailReason, + this.softFailReason, }); final String texSource; @@ -23,6 +53,9 @@ class MathParserResult { /// CSS style, indicating that the widget should render the [texSource] as a /// fallback instead. final List? nodes; + + final KatexParserHardFailReason? hardFailReason; + final KatexParserSoftFailReason? softFailReason; } /// Parses the HTML spans containing KaTeX HTML tree. @@ -88,6 +121,8 @@ MathParserResult? parseMath(dom.Element element, { required bool block }) { final flagForceRenderKatex = globalSettings.getBool(BoolGlobalSetting.forceRenderKatex); + KatexParserHardFailReason? hardFailReason; + KatexParserSoftFailReason? softFailReason; List? nodes; if (flagRenderKatex) { final parser = _KatexParser(); @@ -95,14 +130,24 @@ MathParserResult? parseMath(dom.Element element, { required bool block }) { nodes = parser.parseKatexHtml(katexHtmlElement); } on _KatexHtmlParseError catch (e, st) { assert(debugLog('$e\n$st')); + hardFailReason = KatexParserHardFailReason( + error: e.message ?? 'unknown', + stackTrace: st); } if (parser.hasError && !flagForceRenderKatex) { nodes = null; + softFailReason = KatexParserSoftFailReason( + unsupportedCssClasses: parser.unsupportedCssClasses, + unsupportedInlineCssProperties: parser.unsupportedInlineCssProperties); } } - return MathParserResult(nodes: nodes, texSource: texSource); + return MathParserResult( + nodes: nodes, + texSource: texSource, + hardFailReason: hardFailReason, + softFailReason: softFailReason); } else { return null; } @@ -112,6 +157,9 @@ class _KatexParser { bool get hasError => _hasError; bool _hasError = false; + final unsupportedCssClasses = []; + final unsupportedInlineCssProperties = []; + List parseKatexHtml(dom.Element element) { assert(element.localName == 'span'); assert(element.className == 'katex-html'); @@ -123,7 +171,10 @@ class _KatexParser { if (node case dom.Element(localName: 'span')) { return _parseSpan(node); } else { - throw _KatexHtmlParseError(); + throw _KatexHtmlParseError( + node is dom.Element + ? 'unsupported html node: ${node.localName}' + : 'unsupported html node'); } })); } @@ -374,6 +425,7 @@ class _KatexParser { default: assert(debugLog('KaTeX: Unsupported CSS class: $spanClass')); + unsupportedCssClasses.add(spanClass); _hasError = true; } } @@ -427,6 +479,7 @@ class _KatexParser { // TODO handle more CSS properties assert(debugLog('KaTeX: Unsupported CSS expression:' ' ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); _hasError = true; } else { throw _KatexHtmlParseError(); From 4b90a9a889d558b445c5d904ac5d222b5d785cd5 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 17 Jun 2025 20:05:57 +0530 Subject: [PATCH 269/290] tools/content: Support surveying unimplemented KaTeX features --- tools/content/check-features | 14 +- tools/content/unimplemented_katex_test.dart | 159 ++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 tools/content/unimplemented_katex_test.dart diff --git a/tools/content/check-features b/tools/content/check-features index 76c00f1ce9..747924dc86 100755 --- a/tools/content/check-features +++ b/tools/content/check-features @@ -29,6 +29,11 @@ The steps are: file. This wraps around tools/content/unimplemented_features_test.dart. + katex-check + Check for unimplemented KaTeX features. This requires the corpus + directory \`CORPUS_DIR\` to contain at least one corpus file. + This wraps around tools/content/unimplemented_katex_test.dart. + Options: --config @@ -50,7 +55,7 @@ opt_verbose= opt_steps=() while (( $# )); do case "$1" in - fetch|check) opt_steps+=("$1"); shift;; + fetch|check|katex-check) opt_steps+=("$1"); shift;; --config) shift; opt_zuliprc="$1"; shift;; --verbose) opt_verbose=1; shift;; --help) usage; exit 0;; @@ -98,11 +103,18 @@ run_check() { || return 1 } +run_katex_check() { + flutter test tools/content/unimplemented_katex_test.dart \ + --dart-define=corpusDir="$opt_corpus_dir" \ + || return 1 +} + for step in "${opt_steps[@]}"; do echo "Running ${step}" case "${step}" in fetch) run_fetch ;; check) run_check ;; + katex-check) run_katex_check ;; *) echo >&2 "Internal error: unknown step ${step}" ;; esac done diff --git a/tools/content/unimplemented_katex_test.dart b/tools/content/unimplemented_katex_test.dart new file mode 100644 index 0000000000..ad3769e78f --- /dev/null +++ b/tools/content/unimplemented_katex_test.dart @@ -0,0 +1,159 @@ +// Override `flutter test`'s default timeout +@Timeout(Duration(minutes: 10)) +library; + +import 'dart:io'; +import 'dart:math'; + +import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/content.dart'; +import 'package:zulip/model/settings.dart'; + +import '../../test/model/binding.dart'; +import 'model.dart'; + +void main() async { + TestZulipBinding.ensureInitialized(); + await testBinding.globalStore.settings.setBool( + BoolGlobalSetting.renderKatex, true); + + Future checkForKatexFailuresInFile(File file) async { + int totalMessageCount = 0; + final Set katexMessageIds = {}; + final Set failedKatexMessageIds = {}; + int totalMathBlockNodes = 0; + int failedMathBlockNodes = 0; + int totalMathInlineNodes = 0; + int failedMathInlineNodes = 0; + + final failedMessageIdsByReason = >{}; + final failedMathNodesByReason = >{}; + + void walk(int messageId, DiagnosticsNode node) { + final value = node.value; + if (value is UnimplementedNode) return; + + for (final child in node.getChildren()) { + walk(messageId, child); + } + + if (value is! MathNode) return; + katexMessageIds.add(messageId); + switch (value) { + case MathBlockNode(): totalMathBlockNodes++; + case MathInlineNode(): totalMathInlineNodes++; + } + + if (value.nodes != null) return; + failedKatexMessageIds.add(messageId); + switch (value) { + case MathBlockNode(): failedMathBlockNodes++; + case MathInlineNode(): failedMathInlineNodes++; + } + + final hardFailReason = value.debugHardFailReason; + final softFailReason = value.debugSoftFailReason; + int failureCount = 0; + + if (hardFailReason != null) { + final firstLine = hardFailReason.stackTrace.toString().split('\n').first; + final reason = 'hard fail: ${hardFailReason.error} "$firstLine"'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + + if (softFailReason != null) { + for (final cssClass in softFailReason.unsupportedCssClasses) { + final reason = 'unsupported css class: $cssClass'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + for (final cssProp in softFailReason.unsupportedInlineCssProperties) { + final reason = 'unsupported inline css property: $cssProp'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + failureCount++; + } + } + + if (failureCount == 0) { + final reason = 'unknown'; + (failedMessageIdsByReason[reason] ??= {}).add(messageId); + (failedMathNodesByReason[reason] ??= {}).add(value); + } + } + + await for (final message in readMessagesFromJsonl(file)) { + totalMessageCount++; + walk(message.id, parseContent(message.content).toDiagnosticsNode()); + } + + final buf = StringBuffer(); + buf.writeln(); + buf.writeln('Out of $totalMessageCount total messages,' + ' ${katexMessageIds.length} of them were KaTeX containing messages' + ' and ${failedKatexMessageIds.length} of those failed.'); + buf.writeln('There were $totalMathBlockNodes math block nodes out of which $failedMathBlockNodes failed.'); + buf.writeln('There were $totalMathInlineNodes math inline nodes out of which $failedMathInlineNodes failed.'); + buf.writeln(); + + for (final MapEntry(key: reason, value: messageIds) in failedMessageIdsByReason.entries.sorted( + (a, b) => b.value.length.compareTo(a.value.length), + )) { + final failedMathNodes = failedMathNodesByReason[reason]!.toList(); + failedMathNodes.shuffle(); + final oldestId = messageIds.reduce(min); + final newestId = messageIds.reduce(max); + + buf.writeln('Because of $reason:'); + buf.writeln(' ${messageIds.length} messages failed.'); + buf.writeln(' Oldest message: $oldestId, Newest message: $newestId'); + buf.writeln(' Message IDs (up to 100): ${messageIds.take(100).join(', ')}'); + buf.writeln(' TeX source (up to 30):'); + for (final node in failedMathNodes.take(30)) { + switch (node) { + case MathBlockNode(): + buf.writeln(' ```math'); + for (final line in node.texSource.split('\n')) { + buf.writeln(' $line'); + } + buf.writeln(' ```'); + case MathInlineNode(): + buf.writeln(' \$\$ ${node.texSource} \$\$'); + } + } + buf.writeln(' HTML (up to 3):'); + for (final node in failedMathNodes.take(3)) { + buf.writeln(' ${node.debugHtmlText}'); + } + buf.writeln(); + } + + check(failedKatexMessageIds.length, because: buf.toString()).equals(0); + } + + final corpusFiles = _getCorpusFiles(); + + if (corpusFiles.isEmpty) { + throw Exception('No corpus found in directory "$_corpusDirPath" to check' + ' for katex failures.'); + } + + group('Check for katex failures in', () { + for (final file in corpusFiles) { + test(file.path, () => checkForKatexFailuresInFile(file)); + } + }); +} + +const String _corpusDirPath = String.fromEnvironment('corpusDir'); + +Iterable _getCorpusFiles() { + final corpusDir = Directory(_corpusDirPath); + return corpusDir.existsSync() ? corpusDir.listSync().whereType() : []; +} From 80dcd472a5dadf7fcbe267bb792dc4c10db96820 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 18 Jun 2025 22:25:23 +0530 Subject: [PATCH 270/290] tools/content: Add a flag to control verbosity of KaTeX check result --- tools/content/check-features | 1 + tools/content/unimplemented_katex_test.dart | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/tools/content/check-features b/tools/content/check-features index 747924dc86..7b4698c099 100755 --- a/tools/content/check-features +++ b/tools/content/check-features @@ -106,6 +106,7 @@ run_check() { run_katex_check() { flutter test tools/content/unimplemented_katex_test.dart \ --dart-define=corpusDir="$opt_corpus_dir" \ + --dart-define=verbose="$opt_verbose" \ || return 1 } diff --git a/tools/content/unimplemented_katex_test.dart b/tools/content/unimplemented_katex_test.dart index ad3769e78f..80b0f482a7 100644 --- a/tools/content/unimplemented_katex_test.dart +++ b/tools/content/unimplemented_katex_test.dart @@ -113,6 +113,11 @@ void main() async { buf.writeln('Because of $reason:'); buf.writeln(' ${messageIds.length} messages failed.'); buf.writeln(' Oldest message: $oldestId, Newest message: $newestId'); + if (!_verbose) { + buf.writeln(); + continue; + } + buf.writeln(' Message IDs (up to 100): ${messageIds.take(100).join(', ')}'); buf.writeln(' TeX source (up to 30):'); for (final node in failedMathNodes.take(30)) { @@ -153,6 +158,8 @@ void main() async { const String _corpusDirPath = String.fromEnvironment('corpusDir'); +const bool _verbose = int.fromEnvironment('verbose', defaultValue: 0) != 0; + Iterable _getCorpusFiles() { final corpusDir = Directory(_corpusDirPath); return corpusDir.existsSync() ? corpusDir.listSync().whereType() : []; From 898d907797808ff2597e6a21b8544b3a54264ba4 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 2 Jul 2025 15:40:55 -0700 Subject: [PATCH 271/290] api [nfc]: Remove backward-compat code for mark-read protocol in FL <155 This code had a switch/case on the Narrow type, so I discovered it while implementing keyword-search narrows. We support Zulip Server 7 and later (see README) and refuse to connect to older servers. Since we haven't been using this protocol for servers FL 155+, this is NFC. Related: #992 --- lib/api/route/messages.dart | 64 ------------------- lib/model/unreads.dart | 8 +-- lib/widgets/actions.dart | 51 --------------- test/api/route/messages_test.dart | 72 --------------------- test/widgets/actions_test.dart | 101 ------------------------------ 5 files changed, 3 insertions(+), 293 deletions(-) diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index f55e630585..05364951cd 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -392,9 +392,6 @@ class UpdateMessageFlagsResult { } /// https://zulip.com/api/update-message-flags-for-narrow -/// -/// This binding only supports feature levels 155+. -// TODO(server-6) remove FL 155+ mention in doc, and the related `assert` Future updateMessageFlagsForNarrow(ApiConnection connection, { required Anchor anchor, bool? includeAnchor, @@ -404,7 +401,6 @@ Future updateMessageFlagsForNarrow(ApiConnect required UpdateMessageFlagsOp op, required MessageFlag flag, }) { - assert(connection.zulipFeatureLevel! >= 155); return connection.post('updateMessageFlagsForNarrow', UpdateMessageFlagsForNarrowResult.fromJson, 'messages/flags/narrow', { 'anchor': RawParameter(anchor.toJson()), if (includeAnchor != null) 'include_anchor': includeAnchor, @@ -439,63 +435,3 @@ class UpdateMessageFlagsForNarrowResult { Map toJson() => _$UpdateMessageFlagsForNarrowResultToJson(this); } - -/// https://zulip.com/api/mark-all-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -// -// For FL < 153 this call was atomic on the server and would -// not mark any messages as read if it timed out. -// From FL 153 and onward the server started processing -// in batches so progress could still be made in the event -// of a timeout interruption. Thus, in FL 153 this call -// started returning `result: partially_completed` and -// `code: REQUEST_TIMEOUT` for timeouts. -// -// In FL 211 the `partially_completed` variant of -// `result` was removed, the string `code` field also -// removed, and a boolean `complete` field introduced. -// -// For full support of this endpoint we would need three -// variants of the return structure based on feature -// level (`{}`, `{code: string}`, and `{complete: bool}`) -// as well as handling of `partially_completed` variant -// of `result` in `lib/api/core.dart`. For simplicity we -// ignore these return values. -// -// We don't use this method for FL 155+ (it is replaced -// by `updateMessageFlagsForNarrow`) so there are only -// two versions (FL 153 and FL 154) affected. -Future markAllAsRead(ApiConnection connection) { - return connection.post('markAllAsRead', (_) {}, 'mark_all_as_read', {}); -} - -/// https://zulip.com/api/mark-stream-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -Future markStreamAsRead(ApiConnection connection, { - required int streamId, -}) { - return connection.post('markStreamAsRead', (_) {}, 'mark_stream_as_read', { - 'stream_id': streamId, - }); -} - -/// https://zulip.com/api/mark-topic-as-read -/// -/// This binding is deprecated, in FL 155+ use -/// [updateMessageFlagsForNarrow] instead. -// TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow -Future markTopicAsRead(ApiConnection connection, { - required int streamId, - required TopicName topicName, -}) { - return connection.post('markTopicAsRead', (_) {}, 'mark_topic_as_read', { - 'stream_id': streamId, - 'topic_name': RawParameter(topicName.apiName), - }); -} diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 254b615452..42023611ae 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -441,22 +441,20 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { notifyListeners(); } - /// To be called on success of a mark-all-as-read task in the modern protocol. + /// To be called on success of a mark-all-as-read task. /// /// When the user successfully marks all messages as read, /// there can't possibly be ancient unreads we don't know about. /// So this updates [oldUnreadsMissing] to false and calls [notifyListeners]. /// - /// When we use POST /messages/flags/narrow (FL 155+) for mark-all-as-read, - /// we don't expect to get a mark-as-read event with `all: true`, + /// We don't expect to get a mark-as-read event with `all: true`, /// even on completion of the last batch of unreads. - /// If we did get an event with `all: true` (as we do in the legacy mark-all- + /// If we did get an event with `all: true` (as we did in a legacy mark-all- /// as-read protocol), this would be handled naturally, in /// [handleUpdateMessageFlagsEvent]. /// /// Discussion: /// - // TODO(server-6) Delete mentions of legacy protocol. void handleAllMessagesReadSuccess() { oldUnreadsMissing = false; diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index 61032d81e1..4d96727666 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -26,25 +26,7 @@ abstract final class ZulipAction { /// 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 zulipLocalizations = ZulipLocalizations.of(context); - final useLegacy = store.zulipFeatureLevel < 155; // TODO(server-6) - if (useLegacy) { - try { - await _legacyMarkNarrowAsRead(context, narrow); - return; - } catch (e) { - if (!context.mounted) return; - final message = switch (e) { - ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), - _ => e.toString(), // TODO(#741): extract user-facing message better - }; - showErrorDialog(context: context, - title: zulipLocalizations.errorMarkAsReadFailedTitle, - message: message); - return; - } - } final didPass = await updateMessageFlagsStartingFromAnchor( context: context, @@ -208,39 +190,6 @@ abstract final class ZulipAction { } } - 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; - } - } - /// Fetch and return the raw Markdown content for [messageId], /// showing an error dialog on failure. static Future fetchRawContentWithFeedback({ diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 2a881d2145..f00bf4428f 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -829,76 +829,4 @@ void main() { }); }); }); - - group('markAllAsRead', () { - Future checkMarkAllAsRead( - FakeApiConnection connection, { - required Map expected, - }) async { - connection.prepare(json: {}); - await markAllAsRead(connection); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_all_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkAllAsRead(connection, expected: {}); - }); - }); - }); - - group('markStreamAsRead', () { - Future checkMarkStreamAsRead( - FakeApiConnection connection, { - required int streamId, - required Map expected, - }) async { - connection.prepare(json: {}); - await markStreamAsRead(connection, streamId: streamId); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_stream_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkStreamAsRead(connection, - streamId: 10, - expected: {'stream_id': '10'}); - }); - }); - }); - - group('markTopicAsRead', () { - Future checkMarkTopicAsRead( - FakeApiConnection connection, { - required int streamId, - required String topicName, - required Map expected, - }) async { - connection.prepare(json: {}); - await markTopicAsRead(connection, - streamId: streamId, topicName: eg.t(topicName)); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_topic_as_read') - ..bodyFields.deepEquals(expected); - } - - test('smoke', () { - return FakeApiConnection.with_((connection) async { - await checkMarkTopicAsRead(connection, - streamId: 10, - topicName: 'topic', - expected: { - 'stream_id': '10', - 'topic_name': 'topic', - }); - }); - }); - }); } diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 95e79d9441..6810092f86 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -21,7 +21,6 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; -import '../model/unreads_checks.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; import 'dialog_checks.dart'; @@ -119,106 +118,6 @@ void main() { await future; check(store.unreads.oldUnreadsMissing).isFalse(); }); - - testWidgets('CombinedFeedNarrow on legacy server', (tester) async { - const narrow = CombinedFeedNarrow(); - await prepare(tester); - // Might as well test with oldUnreadsMissing: true. - store.unreads.oldUnreadsMissing = true; - - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_all_as_read') - ..bodyFields.deepEquals({}); - - // Check that [Unreads.handleAllMessagesReadSuccess] wasn't called; - // in the legacy protocol, that'd be redundant with the mark-read event. - check(store.unreads).oldUnreadsMissing.isTrue(); - }); - - testWidgets('ChannelNarrow on legacy server', (tester) async { - final stream = eg.stream(); - final narrow = ChannelNarrow(stream.streamId); - await prepare(tester); - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_stream_as_read') - ..bodyFields.deepEquals({ - 'stream_id': stream.streamId.toString(), - }); - }); - - testWidgets('TopicNarrow on legacy server', (tester) async { - final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - await prepare(tester); - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_topic_as_read') - ..bodyFields.deepEquals({ - 'stream_id': narrow.streamId.toString(), - 'topic_name': narrow.topic, - }); - }); - - testWidgets('DmNarrow on legacy server', (tester) async { - final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); - final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); - final unreadMsgs = eg.unreadMsgs(dms: [ - UnreadDmSnapshot(otherUserId: eg.otherUser.userId, - unreadMessageIds: [message.id]), - ]); - await prepare(tester, unreadMsgs: unreadMsgs); - connection.zulipFeatureLevel = 154; - connection.prepare(json: - UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'read', - }); - }); - - testWidgets('MentionsNarrow on legacy server', (tester) async { - const narrow = MentionsNarrow(); - final message = eg.streamMessage(flags: [MessageFlag.mentioned]); - final unreadMsgs = eg.unreadMsgs(mentions: [message.id]); - await prepare(tester, unreadMsgs: unreadMsgs); - connection.zulipFeatureLevel = 154; - connection.prepare(json: - UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'read', - }); - }); }); group('updateMessageFlagsStartingFromAnchor', () { From 58f5c7ce8c5db3c5e73bb194c7e732e9d90b73a5 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 1 Jul 2025 03:22:22 +0530 Subject: [PATCH 272/290] content: Initial support for inline