From b806f9d8065d613cdb788ad9baf79c4a8452507a Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 19 May 2025 15:01:20 -0400 Subject: [PATCH 001/423] 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 002/423] 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 003/423] 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 004/423] 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 005/423] 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 006/423] 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 007/423] 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 008/423] 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 009/423] 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 010/423] 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 011/423] 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 012/423] 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 013/423] 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 014/423] 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 015/423] 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 016/423] 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 017/423] 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 018/423] 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 019/423] 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 020/423] 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 021/423] 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 022/423] 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 023/423] 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 024/423] 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 025/423] 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 026/423] 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 027/423] 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 028/423] 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 029/423] 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 030/423] 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 031/423] 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 032/423] 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 033/423] 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 034/423] 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 035/423] 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 036/423] 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 037/423] 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 038/423] 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 039/423] 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 040/423] 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 041/423] 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 042/423] 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 043/423] 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 044/423] 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 045/423] 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 046/423] 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 047/423] 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 048/423] 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 049/423] 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 050/423] 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 051/423] 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 052/423] 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 053/423] 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 054/423] 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 055/423] 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 056/423] 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 057/423] 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 058/423] 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 059/423] 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 060/423] 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 061/423] 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 062/423] 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 063/423] 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 064/423] 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 065/423] 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 066/423] 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 067/423] 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 068/423] 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 069/423] 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 070/423] 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 071/423] 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 072/423] 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 073/423] 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 074/423] 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 075/423] 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 076/423] 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 077/423] 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 078/423] 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 079/423] 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 080/423] 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 081/423] 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 082/423] 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 083/423] 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 084/423] 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 085/423] 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 086/423] 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 087/423] 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 088/423] 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 089/423] 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 090/423] 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 091/423] 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 092/423] 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 093/423] 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 094/423] 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 095/423] 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 096/423] 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 097/423] 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 098/423] 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 099/423] 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 100/423] 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 101/423] 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 102/423] 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 103/423] 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 104/423] 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 105/423] 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 106/423] 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 107/423] 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 108/423] 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 109/423] 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 110/423] 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 111/423] 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 112/423] 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 113/423] 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 114/423] 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 115/423] 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 116/423] 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 117/423] 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 118/423] 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 119/423] 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 120/423] 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 121/423] 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 122/423] 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 123/423] 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 124/423] 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 125/423] 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 126/423] 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 127/423] 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 128/423] 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 129/423] 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 130/423] 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 131/423] 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 132/423] 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 133/423] 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 134/423] 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 135/423] 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 136/423] 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 137/423] 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 138/423] 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 139/423] 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 140/423] 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 141/423] 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 142/423] 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 143/423] 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 144/423] 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 145/423] 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 146/423] 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 147/423] 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 148/423] 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 149/423] 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 150/423] 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 151/423] 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 152/423] 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 153/423] 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 154/423] 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 155/423] 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 156/423] 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 157/423] 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 158/423] 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 159/423] 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 160/423] 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 161/423] 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 162/423] 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 163/423] 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 164/423] 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 165/423] 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 166/423] 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 167/423] 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 168/423] 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 169/423] 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 170/423] 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 171/423] 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 172/423] 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 173/423] 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 174/423] 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 175/423] 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 176/423] 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 177/423] 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 178/423] 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 179/423] 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 180/423] 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 181/423] 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 182/423] 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 183/423] 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 184/423] 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 185/423] 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 186/423] 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 187/423] 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 188/423] 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 189/423] 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 190/423] 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 191/423] 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 192/423] 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 193/423] content: Initial support for inline
- UILaunchStoryboardName - LaunchScreen + UILaunchScreen + + UIColorName + LaunchBackground + UIMainStoryboardFile Main UISupportedInterfaceOrientations diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index ff05be7969..6039072116 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -174,7 +174,12 @@ class DesignVariables extends ThemeExtension { listMenuItemBg: const Color(0xffcbcdd6), listMenuItemIcon: const Color(0xff9194a3), listMenuItemText: const Color(0xff2d303c), + + // Keep the color here and the corresponding non-dark mode entry in + // ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json + // in sync. mainBackground: const Color(0xfff0f0f0), + neutralButtonBg: const Color(0xff8c84ae), neutralButtonLabel: const Color(0xff433d5c), radioBorder: Color(0xffbbbdc8), @@ -257,7 +262,12 @@ class DesignVariables extends ThemeExtension { listMenuItemBg: const Color(0xff2d303c), listMenuItemIcon: const Color(0xff767988), listMenuItemText: const Color(0xffcbcdd6), + + // Keep the color here and the corresponding dark mode entry in + // ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json + // in sync. mainBackground: const Color(0xff1d1d1d), + neutralButtonBg: const Color(0xffd4d1e0), neutralButtonLabel: const Color(0xffa9a3c2), radioBorder: Color(0xff626573), From 0ee6336881ee389a4639ed10ecc4799d6096e00a Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 24 Jun 2025 15:55:30 +0430 Subject: [PATCH 272/423] api [nfc]: Add ReactionType.fromApiValue --- lib/api/model/reaction.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/api/model/reaction.dart b/lib/api/model/reaction.dart index 54804d0d05..50d93924d8 100644 --- a/lib/api/model/reaction.dart +++ b/lib/api/model/reaction.dart @@ -175,4 +175,9 @@ enum ReactionType { zulipExtraEmoji; String toJson() => _$ReactionTypeEnumMap[this]!; + + static ReactionType fromApiValue(String value) => _byApiValue[value]!; + + static final _byApiValue = _$ReactionTypeEnumMap + .map((key, value) => MapEntry(value, key)); } From 368df50d2485949a1172dcda8636353b3ae8bbac Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 23 Jun 2025 23:16:25 +0430 Subject: [PATCH 273/423] api: Add InitialSnapshot.userStatuses Co-authored-by: Greg Price Co-authored-by: Chris Bobbe --- lib/api/model/initial_snapshot.dart | 11 +++ lib/api/model/initial_snapshot.g.dart | 102 ++++++++++++----------- lib/api/model/model.dart | 114 ++++++++++++++++++++++++++ test/api/model/model_checks.dart | 17 ++++ test/api/model/model_test.dart | 66 +++++++++++++++ test/example_data.dart | 2 + 6 files changed, 265 insertions(+), 47 deletions(-) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index aed8976c0d..45b97745d6 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -68,6 +68,16 @@ class InitialSnapshot { final List streams; + // In register-queue, the name of this field is the singular "user_status", + // even though it actually contains user status information for all the users + // that the self-user has access to. Therefore, we prefer to use the plural form. + // + // The API expresses each status as a change from the "zero status" (see + // [UserStatus.zero]), with entries omitted for users whose status is the + // zero status. + @JsonKey(name: 'user_status') + final Map userStatuses; + final UserSettings userSettings; final List? userTopics; // TODO(server-6) @@ -151,6 +161,7 @@ class InitialSnapshot { required this.subscriptions, required this.unreadMsgs, required this.streams, + required this.userStatuses, required this.userSettings, required this.userTopics, required this.realmWildcardMentionPolicy, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 8875904ed7..c8b50a7b78 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -73,6 +73,12 @@ InitialSnapshot _$InitialSnapshotFromJson( streams: (json['streams'] as List) .map((e) => ZulipStream.fromJson(e as Map)) .toList(), + userStatuses: (json['user_status'] as Map).map( + (k, e) => MapEntry( + int.parse(k), + UserStatusChange.fromJson(e as Map), + ), + ), userSettings: UserSettings.fromJson( json['user_settings'] as Map, ), @@ -122,53 +128,55 @@ InitialSnapshot _$InitialSnapshotFromJson( .toList(), ); -Map _$InitialSnapshotToJson(InitialSnapshot instance) => - { - 'queue_id': instance.queueId, - 'last_event_id': instance.lastEventId, - 'zulip_feature_level': instance.zulipFeatureLevel, - 'zulip_version': instance.zulipVersion, - 'zulip_merge_base': instance.zulipMergeBase, - 'alert_words': instance.alertWords, - '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': - instance.serverTypingStoppedWaitPeriodMilliseconds, - '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, - 'realm_user_groups': instance.realmUserGroups, - 'recent_private_conversations': instance.recentPrivateConversations, - 'saved_snippets': instance.savedSnippets, - 'subscriptions': instance.subscriptions, - 'unread_msgs': instance.unreadMsgs, - 'streams': instance.streams, - 'user_settings': instance.userSettings, - 'user_topics': instance.userTopics, - 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, - 'realm_mandatory_topics': instance.realmMandatoryTopics, - 'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold, - '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(), - 'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName, - 'realm_users': instance.realmUsers, - 'realm_non_active_users': instance.realmNonActiveUsers, - 'cross_realm_bots': instance.crossRealmBots, - }; +Map _$InitialSnapshotToJson( + InitialSnapshot instance, +) => { + 'queue_id': instance.queueId, + 'last_event_id': instance.lastEventId, + 'zulip_feature_level': instance.zulipFeatureLevel, + 'zulip_version': instance.zulipVersion, + 'zulip_merge_base': instance.zulipMergeBase, + 'alert_words': instance.alertWords, + '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': + instance.serverTypingStoppedWaitPeriodMilliseconds, + '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, + 'realm_user_groups': instance.realmUserGroups, + 'recent_private_conversations': instance.recentPrivateConversations, + 'saved_snippets': instance.savedSnippets, + 'subscriptions': instance.subscriptions, + 'unread_msgs': instance.unreadMsgs, + 'streams': instance.streams, + 'user_status': instance.userStatuses.map((k, e) => MapEntry(k.toString(), e)), + 'user_settings': instance.userSettings, + 'user_topics': instance.userTopics, + 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, + 'realm_mandatory_topics': instance.realmMandatoryTopics, + 'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold, + '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(), + 'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName, + 'realm_users': instance.realmUsers, + 'realm_non_active_users': instance.realmNonActiveUsers, + 'cross_realm_bots': instance.crossRealmBots, +}; const _$EmailAddressVisibilityEnumMap = { EmailAddressVisibility.everyone: 1, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 7aa0e019ce..32d222d620 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../../basic.dart'; import '../../model/algorithms.dart'; import 'events.dart'; import 'initial_snapshot.dart'; @@ -158,6 +159,119 @@ class RealmEmojiItem { Map toJson() => _$RealmEmojiItemToJson(this); } +/// A user's status, with [text] and [emoji] parts. +/// +/// If a part is null, that part is empty/unset. +/// For a [UserStatus] with all parts empty, see [zero]. +class UserStatus { + /// The text part (e.g. 'Working remotely'), or null if unset. + /// + /// This won't be the empty string. + final String? text; + + /// The emoji part, or null if unset. + final StatusEmoji? emoji; + + const UserStatus({required this.text, required this.emoji}) : assert(text != ''); + + static const UserStatus zero = UserStatus(text: null, emoji: null); + + @override + bool operator ==(Object other) { + if (other is! UserStatus) return false; + return (text, emoji) == (other.text, other.emoji); + } + + @override + int get hashCode => Object.hash(text, emoji); +} + +/// A user's status emoji, as in [UserStatus.emoji]. +class StatusEmoji { + final String emojiName; + final String emojiCode; + final ReactionType reactionType; + + const StatusEmoji({ + required this.emojiName, + required this.emojiCode, + required this.reactionType, + }) : assert(emojiName != ''), assert(emojiCode != ''); + + @override + bool operator ==(Object other) { + if (other is! StatusEmoji) return false; + return (emojiName, emojiCode, reactionType) == + (other.emojiName, other.emojiCode, other.reactionType); + } + + @override + int get hashCode => Object.hash(emojiName, emojiCode, reactionType); +} + +/// A change to part or all of a user's status. +/// +/// The absence of one of these means there is no change. +class UserStatusChange { + // final Option away; // deprecated in server-6 (FL-148); ignore + final Option text; + final Option emoji; + + const UserStatusChange({required this.text, required this.emoji}); + + UserStatus apply(UserStatus old) { + return UserStatus(text: text.or(old.text), emoji: emoji.or(old.emoji)); + } + + factory UserStatusChange.fromJson(Map json) { + return UserStatusChange( + text: _textFromJson(json), emoji: _emojiFromJson(json)); + } + + static Option _textFromJson(Map json) { + return switch (json['status_text'] as String?) { + null => OptionNone(), + '' => OptionSome(null), + final apiValue => OptionSome(apiValue), + }; + } + + static Option _emojiFromJson(Map json) { + final emojiName = json['emoji_name'] as String?; + final emojiCode = json['emoji_code'] as String?; + final reactionType = json['reaction_type'] as String?; + + if (emojiName == null || emojiCode == null || reactionType == null) { + return OptionNone(); + } else if (emojiName == '' || emojiCode == '' || reactionType == '') { + // Sometimes `reaction_type` is 'unicode_emoji' when the emoji is cleared. + // This is an accident, to be handled by looking at `emoji_code` instead: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/user.20status/near/2203132 + return OptionSome(null); + } else { + return OptionSome(StatusEmoji( + emojiName: emojiName, + emojiCode: emojiCode, + reactionType: ReactionType.fromApiValue(reactionType))); + } + } + + Map toJson() { + return { + if (text case OptionSome(:var value)) + 'status_text': value ?? '', + if (emoji case OptionSome(:var value)) + ...value == null + ? {'emoji_name': '', 'emoji_code': '', 'reaction_type': ''} + : { + 'emoji_name': value.emojiName, + 'emoji_code': value.emojiCode, + 'reaction_type': value.reactionType, + }, + }; + } +} + /// The name of a user setting that has a property in [UserSettings]. /// /// In Zulip event-handling code (for [UserSettingsUpdateEvent]), diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 36b6396c97..201a0ef7b6 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -1,6 +1,23 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; +import 'package:zulip/basic.dart'; + +extension UserStatusChecks on Subject { + Subject get text => has((x) => x.text, 'text'); + Subject get emoji => has((x) => x.emoji, 'emoji'); +} + +extension StatusEmojiChecks on Subject { + Subject get emojiName => has((x) => x.emojiName, 'emojiName'); + Subject get emojiCode => has((x) => x.emojiCode, 'emojiCode'); + Subject get reactionType => has((x) => x.reactionType, 'reactionType'); +} + +extension UserStatusChangeChecks on Subject { + Subject> get text => has((x) => x.text, 'text'); + Subject> get emoji => has((x) => x.emoji, 'emoji'); +} extension UserGroupChecks on Subject { Subject get id => has((x) => x.id, 'id'); diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index 6012f29ead..fa0445ca98 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -4,6 +4,7 @@ import 'package:checks/checks.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import '../../example_data.dart' as eg; import '../../stdlib_checks.dart'; @@ -25,6 +26,71 @@ void main() { }); }); + test('UserStatusChange', () { + void doCheck({ + required (String? statusText, String? emojiName, + String? emojiCode, String? reactionType) incoming, + required (Option text, Option emoji) expected, + }) { + check(UserStatusChange.fromJson({ + 'status_text': incoming.$1, + 'emoji_name': incoming.$2, + 'emoji_code': incoming.$3, + 'reaction_type': incoming.$4, + })) + ..text.equals(expected.$1) + ..emoji.equals(expected.$2); + } + + doCheck( + incoming: ('Busy', 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionSome('Busy'), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: ('', 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionSome(null), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: (null, 'working_on_it', '1f6e0', 'unicode_emoji'), + expected: (OptionNone(), OptionSome(StatusEmoji( + emojiName: 'working_on_it', + emojiCode: '1f6e0', + reactionType: ReactionType.unicodeEmoji)))); + + doCheck( + incoming: ('Busy', '', '', ''), + expected: (OptionSome('Busy'), OptionSome(null))); + + doCheck( + incoming: ('Busy', null, null, null), + expected: (OptionSome('Busy'), OptionNone())); + + doCheck( + incoming: ('', '', '', ''), + expected: (OptionSome(null), OptionSome(null))); + + doCheck( + incoming: (null, null, null, null), + expected: (OptionNone(), OptionNone())); + + // For the API quirk when `reaction_type` is 'unicode_emoji' when the + // emoji is cleared. + doCheck( + incoming: ('', '', '', 'unicode_emoji'), + expected: (OptionSome(null), OptionSome(null))); + + // Hardly likely to happen from the API standpoint, but we handle it anyway. + doCheck( + incoming: (null, null, null, 'unicode_emoji'), + expected: (OptionNone(), OptionNone())); + }); + group('User', () { final Map baseJson = Map.unmodifiable({ 'user_id': 123, diff --git a/test/example_data.dart b/test/example_data.dart index 15b1ff559f..fc32781a1e 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1138,6 +1138,7 @@ InitialSnapshot initialSnapshot({ List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List? streams, + Map? userStatuses, UserSettings? userSettings, List? userTopics, RealmWildcardMentionPolicy? realmWildcardMentionPolicy, @@ -1180,6 +1181,7 @@ InitialSnapshot initialSnapshot({ subscriptions: subscriptions ?? [], // TODO add subscriptions to default unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default + userStatuses: userStatuses ?? {}, userSettings: userSettings ?? UserSettings( twentyFourHourTime: false, displayEmojiReactionUsers: true, From 3d814d297a5379ace0668773a5f9e4f8a9939773 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 23 Jun 2025 23:44:11 +0430 Subject: [PATCH 274/423] api: Add user_status event Co-authored-by: Chris Bobbe --- lib/api/model/events.dart | 36 ++++++++++++++++++++++++++++++++++++ lib/api/model/events.g.dart | 9 +++++++++ lib/model/store.dart | 4 ++++ 3 files changed, 49 insertions(+) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index c539c350d6..7189cd28ef 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -69,6 +69,7 @@ sealed class Event { default: return UnexpectedEvent.fromJson(json); } // case 'muted_topics': … // TODO(#422) we ignore this feature on older servers + case 'user_status': return UserStatusEvent.fromJson(json); case 'user_topic': return UserTopicEvent.fromJson(json); case 'muted_users': return MutedUsersEvent.fromJson(json); case 'message': return MessageEvent.fromJson(json); @@ -797,6 +798,41 @@ class SubscriptionPeerRemoveEvent extends SubscriptionEvent { Map toJson() => _$SubscriptionPeerRemoveEventToJson(this); } +/// A Zulip event of type `user_status`: https://zulip.com/api/get-events#user_status +@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) +class UserStatusEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'user_status'; + + final int userId; + + @JsonKey(readValue: _readChange) + final UserStatusChange change; + + static Object? _readChange(Map json, String key) { + assert(json is Map); // value came through `fromJson` with this type + return json; + } + + UserStatusEvent({ + required super.id, + required this.userId, + required this.change, + }); + + factory UserStatusEvent.fromJson(Map json) => + _$UserStatusEventFromJson(json); + + @override + Map toJson() => { + 'id': id, + 'type': type, + 'user_id': userId, + ...change.toJson(), + }; +} + /// A Zulip event of type `user_topic`: https://zulip.com/api/get-events#user_topic @JsonSerializable(fieldRename: FieldRename.snake) class UserTopicEvent extends Event { diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index b787b41443..1aa93ef47b 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -502,6 +502,15 @@ Map _$SubscriptionPeerRemoveEventToJson( 'user_ids': instance.userIds, }; +UserStatusEvent _$UserStatusEventFromJson(Map json) => + UserStatusEvent( + id: (json['id'] as num).toInt(), + userId: (json['user_id'] as num).toInt(), + change: UserStatusChange.fromJson( + UserStatusEvent._readChange(json, 'change') as Map, + ), + ); + UserTopicEvent _$UserTopicEventFromJson(Map json) => UserTopicEvent( id: (json['id'] as num).toInt(), diff --git a/lib/model/store.dart b/lib/model/store.dart index ff0fadb219..0e0904d6a7 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -959,6 +959,10 @@ class PerAccountStore extends PerAccountStoreBase with _channels.handleSubscriptionEvent(event); notifyListeners(); + case UserStatusEvent(): + // TODO: handle + break; + case UserTopicEvent(): assert(debugLog("server event: user_topic")); _messages.handleUserTopicEvent(event); From ce1c664f52e8cdbc36fe317f3c18cc9bd28dc9b1 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 24 Jun 2025 00:39:06 +0430 Subject: [PATCH 275/423] store: Add UserStore.getUserStatus, with event updates Co-authored-by: Chris Bobbe --- lib/model/store.dart | 8 +++- lib/model/user.dart | 19 ++++++++- test/model/user_test.dart | 82 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 0e0904d6a7..13a999aa37 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -688,6 +688,9 @@ class PerAccountStore extends PerAccountStoreBase with MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) => _users.mightChangeShouldMuteDmConversation(event); + @override + UserStatus getUserStatus(int userId) => _users.getUserStatus(userId); + final UserStoreImpl _users; final TypingStatus typingStatus; @@ -960,8 +963,9 @@ class PerAccountStore extends PerAccountStoreBase with notifyListeners(); case UserStatusEvent(): - // TODO: handle - break; + assert(debugLog("server event: user_status")); + _users.handleUserStatusEvent(event); + notifyListeners(); case UserTopicEvent(): assert(debugLog("server event: user_topic")); diff --git a/lib/model/user.dart b/lib/model/user.dart index 23c4d53816..13eb91f7d0 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -102,6 +102,11 @@ mixin UserStore on PerAccountStoreBase { /// Whether the given event might change the result of [shouldMuteDmConversation] /// for its list of muted users, compared to the current state. MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event); + + /// The status of the user with the given ID. + /// + /// If no status is set for the user, returns [UserStatus.zero]. + UserStatus getUserStatus(int userId); } /// Whether and how a given [MutedUsersEvent] may affect the results @@ -135,7 +140,9 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { .followedBy(initialSnapshot.realmNonActiveUsers) .followedBy(initialSnapshot.crossRealmBots) .map((user) => MapEntry(user.userId, user))), - _mutedUsers = Set.from(initialSnapshot.mutedUsers.map((item) => item.id)); + _mutedUsers = Set.from(initialSnapshot.mutedUsers.map((item) => item.id)), + _userStatuses = initialSnapshot.userStatuses.map((userId, change) => + MapEntry(userId, change.apply(UserStatus.zero))); final Map _users; @@ -175,6 +182,11 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { } } + final Map _userStatuses; + + @override + UserStatus getUserStatus(int userId) => _userStatuses[userId] ?? UserStatus.zero; + void handleRealmUserEvent(RealmUserEvent event) { switch (event) { case RealmUserAddEvent(): @@ -214,6 +226,11 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { } } + void handleUserStatusEvent(UserStatusEvent event) { + _userStatuses[event.userId] = + event.change.apply(getUserStatus(event.userId)); + } + 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 50e8c71db7..aa978ddb58 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -10,6 +10,9 @@ import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; import 'test_store.dart'; +typedef StatusData = (String? statusText, String? emojiName, String? emojiCode, + String? reactionType); + void main() { group('userDisplayName', () { test('on a known user', () async { @@ -83,6 +86,85 @@ void main() { }); }); + testWidgets('UserStatusEvent', (tester) async { + UserStatusChange userStatus(StatusData data) => UserStatusChange.fromJson({ + 'status_text': data.$1, + 'emoji_name': data.$2, + 'emoji_code': data.$3, + 'reaction_type': data.$4, + }); + + void checkUserStatus(UserStatus userStatus, StatusData expected) { + check(userStatus).text.equals(expected.$1); + + switch (expected) { + case (_, String emojiName, String emojiCode, String reactionType): + check(userStatus.emoji!) + ..emojiName.equals(emojiName) + ..emojiCode.equals(emojiCode) + ..reactionType.equals(ReactionType.fromApiValue(reactionType)); + default: + check(userStatus.emoji).isNull(); + } + } + + UserStatusEvent userStatusEvent(StatusData data, {required int userId}) => + UserStatusEvent( + id: 1, + userId: userId, + change: UserStatusChange.fromJson({ + 'status_text': data.$1, + 'emoji_name': data.$2, + 'emoji_code': data.$3, + 'reaction_type': data.$4, + })); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + userStatuses: { + 1: userStatus(('Busy', 'working_on_it', '1f6e0', 'unicode_emoji')), + 2: userStatus((null, 'calendar', '1f4c5', 'unicode_emoji')), + 3: userStatus(('Commuting', null, null, null)), + })); + checkUserStatus(store.getUserStatus(1), + ('Busy', 'working_on_it', '1f6e0', 'unicode_emoji')); + checkUserStatus(store.getUserStatus(2), + (null, 'calendar', '1f4c5', 'unicode_emoji')); + checkUserStatus(store.getUserStatus(3), + ('Commuting', null, null, null)); + check(store.getUserStatus(4))..text.isNull()..emoji.isNull(); + check(store.getUserStatus(5))..text.isNull()..emoji.isNull(); + + await store.handleEvent(userStatusEvent(userId: 1, + ('Out sick', 'sick', '1f912', 'unicode_emoji'))); + checkUserStatus(store.getUserStatus(1), + ('Out sick', 'sick', '1f912', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 2, + ('In a meeting', null, null, null))); + checkUserStatus(store.getUserStatus(2), + ('In a meeting', 'calendar', '1f4c5', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 3, + ('', 'bus', '1f68c', 'unicode_emoji'))); + checkUserStatus(store.getUserStatus(3), + (null, 'bus', '1f68c', 'unicode_emoji')); + + await store.handleEvent(userStatusEvent(userId: 4, + ('Vacationing', null, null, null))); + checkUserStatus(store.getUserStatus(4), + ('Vacationing', null, null, null)); + + await store.handleEvent(userStatusEvent(userId: 5, + ('Working remotely', '', '', ''))); + checkUserStatus(store.getUserStatus(5), + ('Working remotely', null, null, null)); + + await store.handleEvent(userStatusEvent(userId: 1, + ('', '', '', ''))); + checkUserStatus(store.getUserStatus(1), + (null, null, null, null)); + }); + group('MutedUsersEvent', () { testWidgets('smoke', (tester) async { late PerAccountStore store; From dc686ef768407534b1e11a626442b075020304fb Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 8 Jul 2025 01:34:33 +0430 Subject: [PATCH 276/423] emoji [nfc]: Remove UnicodeEmojiWidget.notoColorEmojiTextSize Instead of passing this property each time the widget is used, we now calculate it internally. See discussion: https://github.com/zulip/zulip-flutter/pull/1629#discussion_r2188037245 --- lib/widgets/action_sheet.dart | 1 - lib/widgets/autocomplete.dart | 5 +---- lib/widgets/emoji.dart | 27 ++++++++++++++++++--------- lib/widgets/emoji_reaction.dart | 13 +------------ 4 files changed, 20 insertions(+), 26 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index cf50fc4494..6b280df6ee 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -774,7 +774,6 @@ class ReactionButtons extends StatelessWidget { : null, child: UnicodeEmojiWidget( emojiDisplay: emoji.emojiDisplay as UnicodeEmojiDisplay, - notoColorEmojiTextSize: 20.1, size: 24)))); } diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index bfb633ee66..526c7edfb1 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -327,7 +327,6 @@ class _EmojiAutocompleteItem extends StatelessWidget { final EmojiAutocompleteResult option; static const _size = 24.0; - static const _notoColorEmojiTextSize = 19.3; @override Widget build(BuildContext context) { @@ -341,9 +340,7 @@ class _EmojiAutocompleteItem extends StatelessWidget { ImageEmojiDisplay() => ImageEmojiWidget(size: _size, emojiDisplay: emojiDisplay), UnicodeEmojiDisplay() => - UnicodeEmojiWidget( - size: _size, notoColorEmojiTextSize: _notoColorEmojiTextSize, - emojiDisplay: emojiDisplay), + UnicodeEmojiWidget(size: _size, emojiDisplay: emojiDisplay), TextEmojiDisplay() => null, // The text is already shown separately. }; diff --git a/lib/widgets/emoji.dart b/lib/widgets/emoji.dart index dafeb7b6d8..9cb9a01e8a 100644 --- a/lib/widgets/emoji.dart +++ b/lib/widgets/emoji.dart @@ -9,7 +9,6 @@ class UnicodeEmojiWidget extends StatelessWidget { super.key, required this.emojiDisplay, required this.size, - required this.notoColorEmojiTextSize, this.textScaler = TextScaler.noScaling, }); @@ -20,12 +19,6 @@ class UnicodeEmojiWidget extends StatelessWidget { /// This will be scaled by [textScaler]. final double size; - /// A font size that, with Noto Color Emoji and our line-height config, - /// causes a Unicode emoji to occupy a square of size [size] in the layout. - /// - /// This has to be determined experimentally, as far as we know. - final double notoColorEmojiTextSize; - /// The text scaler to apply to [size]. /// /// Defaults to [TextScaler.noScaling]. @@ -38,6 +31,15 @@ class UnicodeEmojiWidget extends StatelessWidget { case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: + // A font size that, with Noto Color Emoji and our line-height + // config (the use of `forceStrutHeight: true`), causes a Unicode emoji + // to occupy a square of size [size] in the layout. + // + // Determined experimentally: + // + // + final double notoColorEmojiTextSize = size * (14.5 / 17); + return Text( textScaler: textScaler, style: TextStyle( @@ -45,7 +47,10 @@ class UnicodeEmojiWidget extends StatelessWidget { fontSize: notoColorEmojiTextSize, ), strutStyle: StrutStyle( - fontSize: notoColorEmojiTextSize, forceStrutHeight: true), + fontSize: notoColorEmojiTextSize, + // Responsible for keeping the line height constant, even + // with ambient DefaultTextStyle. + forceStrutHeight: true), emojiDisplay.emojiUnicode); case TargetPlatform.iOS: @@ -74,7 +79,11 @@ class UnicodeEmojiWidget extends StatelessWidget { style: TextStyle( fontFamily: 'Apple Color Emoji', fontSize: size), - strutStyle: StrutStyle(fontSize: size, forceStrutHeight: true), + strutStyle: StrutStyle( + fontSize: size, + // Responsible for keeping the line height constant, even + // with ambient DefaultTextStyle. + forceStrutHeight: true), emojiDisplay.emojiUnicode)), ]); } diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index b400940ec6..3c26361d3a 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -270,13 +270,6 @@ class ReactionChip extends StatelessWidget { /// Should be scaled by [_emojiTextScalerClamped]. const _squareEmojiSize = 17.0; -/// A font size that, with Noto Color Emoji and our line-height config, -/// causes a Unicode emoji to occupy a [_squareEmojiSize] square in the layout. -/// -/// Determined experimentally: -/// -const _notoColorEmojiTextSize = 14.5; - /// A [TextScaler] that limits Unicode and image emojis' max scale factor, /// to leave space for the label. /// @@ -306,7 +299,6 @@ class _UnicodeEmoji extends StatelessWidget { Widget build(BuildContext context) { return UnicodeEmojiWidget( size: _squareEmojiSize, - notoColorEmojiTextSize: _notoColorEmojiTextSize, textScaler: _squareEmojiScalerClamped(context), emojiDisplay: emojiDisplay); } @@ -563,7 +555,6 @@ class EmojiPickerListEntry extends StatelessWidget { final Message message; static const _emojiSize = 24.0; - static const _notoColorEmojiTextSize = 20.1; void _onPressed() { // Dismiss the enclosing action sheet immediately, @@ -590,9 +581,7 @@ class EmojiPickerListEntry extends StatelessWidget { ImageEmojiDisplay() => ImageEmojiWidget(size: _emojiSize, emojiDisplay: emojiDisplay), UnicodeEmojiDisplay() => - UnicodeEmojiWidget( - size: _emojiSize, notoColorEmojiTextSize: _notoColorEmojiTextSize, - emojiDisplay: emojiDisplay), + UnicodeEmojiWidget(size: _emojiSize, emojiDisplay: emojiDisplay), TextEmojiDisplay() => null, // The text is already shown separately. }; From 3df6507fc4ce84ceb738e4f27b553314446ead21 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 25 Jun 2025 00:55:11 +0430 Subject: [PATCH 277/423] emoji [nfc]: Add ImageEmojiWidget.neverAnimate --- lib/widgets/emoji.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/widgets/emoji.dart b/lib/widgets/emoji.dart index 9cb9a01e8a..ba26af3450 100644 --- a/lib/widgets/emoji.dart +++ b/lib/widgets/emoji.dart @@ -98,6 +98,7 @@ class ImageEmojiWidget extends StatelessWidget { required this.size, this.textScaler = TextScaler.noScaling, this.errorBuilder, + this.neverAnimate = false, }); final ImageEmojiDisplay emojiDisplay; @@ -114,13 +115,20 @@ class ImageEmojiWidget extends StatelessWidget { final ImageErrorWidgetBuilder? errorBuilder; + /// Whether to show an animated emoji in its still (non-animated) variant + /// only, even if device settings permit animation. + /// + /// Defaults to false. + final bool neverAnimate; + @override Widget build(BuildContext context) { // Some people really dislike animated emoji. final doNotAnimate = + neverAnimate // From reading code, this doesn't actually get set on iOS: // https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293 - MediaQuery.disableAnimationsOf(context) + || MediaQuery.disableAnimationsOf(context) || (defaultTargetPlatform == TargetPlatform.iOS // TODO(upstream) On iOS 17+ (new in 2023), there's a more closely // relevant setting than "reduce motion". It's called "auto-play From 85db2af0ff5924906d0d3291631dfa3fdd54ee57 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 25 Jun 2025 00:57:02 +0430 Subject: [PATCH 278/423] content: Add UserStatusEmoji widget Co-authored-by: Chris Bobbe --- lib/widgets/content.dart | 88 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index b49fdb4d9c..5f0171b5a5 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -14,12 +14,14 @@ import '../generated/l10n/zulip_localizations.dart'; import '../model/avatar_url.dart'; import '../model/binding.dart'; import '../model/content.dart'; +import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/katex.dart'; import '../model/presence.dart'; import 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; +import 'emoji.dart'; import 'icons.dart'; import 'inset_shadow.dart'; import 'lightbox.dart'; @@ -1940,6 +1942,92 @@ class _PresenceCircleState extends State with PerAccountStoreAwa } } +/// A user status emoji to be displayed in different parts of the app. +/// +/// Use [padding] to control the padding of status emoji from neighboring +/// widgets. +/// When there is no status emoji to be shown, the padding will be omitted too. +/// +/// Use [neverAnimate] to forcefully disable the animation for animated emojis. +/// Defaults to true. +class UserStatusEmoji extends StatelessWidget { + const UserStatusEmoji({ + super.key, + required this.userId, + required this.size, + this.padding = EdgeInsets.zero, + this.neverAnimate = true, + }); + + final int userId; + final double size; + final EdgeInsetsGeometry padding; + final bool neverAnimate; + + static const double _spanPadding = 4; + + /// Creates a [WidgetSpan] with a [UserStatusEmoji], for use in rich text; + /// before or after a text span. + /// + /// Use [position] to tell the emoji span where it is located relative to + /// another span, so that it can adjust the necessary padding from it. + static InlineSpan asWidgetSpan({ + required int userId, + required double fontSize, + required TextScaler textScaler, + StatusEmojiPosition position = StatusEmojiPosition.after, + bool neverAnimate = true, + }) { + final (double paddingStart, double paddingEnd) = switch (position) { + StatusEmojiPosition.before => (0, _spanPadding), + StatusEmojiPosition.after => (_spanPadding, 0), + }; + final size = textScaler.scale(fontSize); + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: UserStatusEmoji(userId: userId, size: size, + padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), + neverAnimate: neverAnimate)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final emoji = store.getUserStatus(userId).emoji; + + final placeholder = SizedBox.shrink(); + if (emoji == null) return placeholder; + + final emojiDisplay = store.emojiDisplayFor( + emojiType: emoji.reactionType, + emojiCode: emoji.emojiCode, + emojiName: emoji.emojiName) + // Web doesn't seem to respect the emojiset user settings for user status. + // .resolve(store.userSettings) + ; + return switch (emojiDisplay) { + UnicodeEmojiDisplay() => Padding( + padding: padding, + child: UnicodeEmojiWidget(size: size, emojiDisplay: emojiDisplay)), + ImageEmojiDisplay() => Padding( + padding: padding, + child: ImageEmojiWidget( + size: size, + emojiDisplay: emojiDisplay, + neverAnimate: neverAnimate, + // If image emoji fails to load, show nothing. + errorBuilder: (_, _, _) => placeholder)), + // The user-status feature doesn't support a :text_emoji:-style display. + // Also, if an image emoji's URL string doesn't parse, it'll fall back to + // a :text_emoji:-style display. We show nothing for this case. + TextEmojiDisplay() => placeholder, + }; + } +} + +/// The position of the status emoji span relative to another text span. +enum StatusEmojiPosition { before, after } + // // Small helpers. // From 4b75025cfa95222cee7df9084c4b8963d810ac4c Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:44:54 +0430 Subject: [PATCH 279/423] test: Add `changeUserStatus(es)` to PerAccountStoreTestExtension Co-authored-by: Chris Bobbe --- test/model/test_store.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 1d086d4290..e77b5fc2a0 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -271,6 +271,16 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(eg.mutedUsersEvent(userIds)); } + Future changeUserStatus(int userId, UserStatusChange change) async { + await handleEvent(UserStatusEvent(id: 1, userId: userId, change: change)); + } + + Future changeUserStatuses(List<(int userId, UserStatusChange change)> changes) async { + for (final (userId, change) in changes) { + await changeUserStatus(userId, change); + } + } + Future addStream(ZulipStream stream) async { await addStreams([stream]); } From a451d581029713adff4d4fe7b2d4007587b404ef Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 7 Jul 2025 12:44:31 -0700 Subject: [PATCH 280/423] store [nfc]: s/isLoading/isRecoveringEventStream/ --- lib/model/store.dart | 16 ++++++++-------- lib/widgets/app_bar.dart | 2 +- test/model/store_checks.dart | 2 +- test/model/store_test.dart | 12 ++++++------ test/widgets/app_bar_test.dart | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 13a999aa37..ac58f14991 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -582,12 +582,12 @@ class PerAccountStore extends PerAccountStoreBase with _updateMachine = value; } - bool get isLoading => _isLoading; - bool _isLoading = false; + bool get isRecoveringEventStream => _isRecoveringEventStream; + bool _isRecoveringEventStream = false; @visibleForTesting - set isLoading(bool value) { - if (_isLoading == value) return; - _isLoading = value; + set isRecoveringEventStream(bool value) { + if (_isRecoveringEventStream == value) return; + _isRecoveringEventStream = value; notifyListeners(); } @@ -1511,7 +1511,7 @@ class UpdateMachine { // and failures, the successes themselves should space out the requests. _pollBackoffMachine = null; - store.isLoading = false; + store.isRecoveringEventStream = false; _accumulatedTransientFailureCount = 0; reportErrorToUserBriefly(null); } @@ -1530,7 +1530,7 @@ class UpdateMachine { /// * [_handlePollError], which handles errors from the rest of [poll] /// and errors this method rethrows. Future _handlePollRequestError(Object error, StackTrace stackTrace) async { - store.isLoading = true; + store.isRecoveringEventStream = true; if (error is! ApiRequestException) { // Some unexpected error, outside even making the HTTP request. @@ -1588,7 +1588,7 @@ class UpdateMachine { // or an unexpected exception representing a bug in our code or the server. // Either way, the show must go on. So reload server data from scratch. - store.isLoading = true; + store.isRecoveringEventStream = true; bool isUnexpected; // TODO(#1054): handle auth failure diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index f548557681..77f77ba7e2 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -79,7 +79,7 @@ class _ZulipAppBarBottom extends StatelessWidget implements PreferredSizeWidget @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - if (!store.isLoading) return const SizedBox.shrink(); + if (!store.isRecoveringEventStream) return const SizedBox.shrink(); return LinearProgressIndicator(minHeight: 4.0, backgroundColor: backgroundColor); } } diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 93e24dffdd..c157ac2190 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -45,7 +45,7 @@ extension GlobalStoreChecks on Subject { extension PerAccountStoreChecks on Subject { Subject get connection => has((x) => x.connection, 'connection'); - Subject get isLoading => has((x) => x.isLoading, 'isLoading'); + Subject get isRecoveringEventStream => has((x) => x.isRecoveringEventStream, 'isRecoveringEventStream'); Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); Subject get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion'); Subject get realmMandatoryTopics => has((x) => x.realmMandatoryTopics, 'realmMandatoryTopics'); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 3736064f84..eac9aa22dc 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -841,7 +841,7 @@ void main() { await prepareError(); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store).isLoading.isTrue(); + check(store).isRecoveringEventStream.isTrue(); if (expectBackoff) { // The reload doesn't happen immediately; there's a timer. @@ -853,7 +853,7 @@ void main() { // The global store has a new store. check(globalStore.perAccountSync(store.accountId)).not((it) => it.identicalTo(store)); updateFromGlobalStore(); - check(store).isLoading.isFalse(); + check(store).isRecoveringEventStream.isFalse(); // The new UpdateMachine updates the new store. updateMachine.debugPauseLoop(); @@ -879,7 +879,7 @@ void main() { updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); checkLastRequest(lastEventId: 1); - check(store).isLoading.isTrue(); + check(store).isRecoveringEventStream.isTrue(); // Polling doesn't resume immediately; there's a timer. check(async.pendingTimers).length.equals(1); @@ -895,7 +895,7 @@ void main() { async.flushTimers(); checkLastRequest(lastEventId: 1); check(updateMachine.lastEventId).equals(2); - check(store).isLoading.isFalse(); + check(store).isRecoveringEventStream.isFalse(); }); } @@ -1036,7 +1036,7 @@ void main() { updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); if (shouldCheckRequest) checkLastRequest(lastEventId: 1); - check(store).isLoading.isTrue(); + check(store).isRecoveringEventStream.isTrue(); } Subject checkReported(void Function() prepareError) { @@ -1186,7 +1186,7 @@ void main() { globalStore.clearCachedApiConnections(); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); // the bad-event-queue error arrives - check(store).isLoading.isTrue(); + check(store).isRecoveringEventStream.isTrue(); } test('user logged out before new store is loaded', () => awaitFakeAsync((async) async { diff --git a/test/widgets/app_bar_test.dart b/test/widgets/app_bar_test.dart index 099471d4f7..f4178b455a 100644 --- a/test/widgets/app_bar_test.dart +++ b/test/widgets/app_bar_test.dart @@ -31,7 +31,7 @@ void main() { await tester.pumpAndSettle(); final rectBefore = tester.getRect(find.byType(ZulipAppBar)); check(finder.evaluate()).isEmpty(); - store.isLoading = true; + store.isRecoveringEventStream = true; await tester.pump(); check(tester.getRect(find.byType(ZulipAppBar))).equals(rectBefore); From f8b0e8bf1d24549256427ceceeb1dce16a4374c7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 7 Jul 2025 13:19:19 -0700 Subject: [PATCH 281/423] store: Pass `dont_block` in first poll attempt after a failure Fixes #979. Discussion: https://github.com/zulip/zulip-flutter/issues/979#issuecomment-2993296322 --- lib/model/store.dart | 18 ++++++++++++------ test/model/store_test.dart | 31 ++++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index ac58f14991..9973cbc33e 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -1437,7 +1437,13 @@ class UpdateMachine { final GetEventsResult result; try { result = await getEvents(store.connection, - queueId: store.queueId, lastEventId: lastEventId); + queueId: store.queueId, + lastEventId: lastEventId, + // If the UI shows we're busy getting event-polling to work again, + // ask the server to tell us immediately that it's working again, + // rather than waiting for an event, which could take up to a minute + // in the case of a heartbeat event. See #979. + dontBlock: store.isRecoveringEventStream ? true : null); if (_disposed) return; } catch (e, stackTrace) { if (_disposed) return; @@ -1504,11 +1510,11 @@ class UpdateMachine { // if we stayed at the max backoff interval; partway toward what would // happen if we weren't backing off at all. // - // But at least for [getEvents] requests, as here, it should be OK, - // because this is a long-poll. That means a typical successful request - // takes a long time to come back; in fact longer than our max backoff - // duration (which is 10 seconds). So if we're getting a mix of successes - // and failures, the successes themselves should space out the requests. + // Successful retries won't actually space out the requests, because retries + // are done with the `dont_block` param, asking the server to respond + // immediately instead of waiting through the long-poll period. + // (See comments on that code for why this behavior is helpful.) + // If server logs show pressure from too many requests, we can investigate. _pollBackoffMachine = null; store.isRecoveringEventStream = false; diff --git a/test/model/store_test.dart b/test/model/store_test.dart index eac9aa22dc..5ce42626cf 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -782,13 +782,14 @@ void main() { updateMachine.poll(); } - void checkLastRequest({required int lastEventId}) { + void checkLastRequest({required int lastEventId, bool expectDontBlock = false}) { check(connection.takeRequests()).single.isA() ..method.equals('GET') ..url.path.equals('/api/v1/events') ..url.queryParameters.deepEquals({ 'queue_id': store.queueId, 'last_event_id': lastEventId.toString(), + if (expectDontBlock) 'dont_block': 'true', }); } @@ -878,7 +879,7 @@ void main() { prepareError(); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - checkLastRequest(lastEventId: 1); + checkLastRequest(lastEventId: 1, expectDontBlock: false); check(store).isRecoveringEventStream.isTrue(); // Polling doesn't resume immediately; there's a timer. @@ -893,7 +894,7 @@ void main() { HeartbeatEvent(id: 2), ], queueId: null).toJson()); async.flushTimers(); - checkLastRequest(lastEventId: 1); + checkLastRequest(lastEventId: 1, expectDontBlock: true); check(updateMachine.lastEventId).equals(2); check(store).isRecoveringEventStream.isFalse(); }); @@ -1032,10 +1033,12 @@ void main() { await preparePoll(lastEventId: 1); } - void pollAndFail(FakeAsync async, {bool shouldCheckRequest = true}) { + void pollAndFail(FakeAsync async, {bool shouldCheckRequest = true, bool expectDontBlock = false}) { updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - if (shouldCheckRequest) checkLastRequest(lastEventId: 1); + if (shouldCheckRequest) { + checkLastRequest(lastEventId: 1, expectDontBlock: expectDontBlock); + } check(store).isRecoveringEventStream.isTrue(); } @@ -1054,9 +1057,11 @@ void main() { return awaitFakeAsync((async) async { await prepare(); + bool expectDontBlock = false; for (int i = 0; i < UpdateMachine.transientFailureCountNotifyThreshold; i++) { prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); + expectDontBlock = true; check(takeLastReportedError()).isNull(); async.flushTimers(); if (!identical(store, globalStore.perAccountSync(store.accountId))) { @@ -1064,11 +1069,14 @@ void main() { updateFromGlobalStore(); updateMachine.debugPauseLoop(); updateMachine.poll(); + // Loading indicator is cleared on successful /register; + // we don't need dont_block for the new queue's first poll. + expectDontBlock = false; } } prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); return check(takeLastReportedError()).isNotNull(); }); } @@ -1077,9 +1085,11 @@ void main() { return awaitFakeAsync((async) async { await prepare(); + bool expectDontBlock = false; for (int i = 0; i < UpdateMachine.transientFailureCountNotifyThreshold; i++) { prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); + expectDontBlock = true; check(takeLastReportedError()).isNull(); async.flushTimers(); if (!identical(store, globalStore.perAccountSync(store.accountId))) { @@ -1087,11 +1097,14 @@ void main() { updateFromGlobalStore(); updateMachine.debugPauseLoop(); updateMachine.poll(); + // Loading indicator is cleared on successful /register; + // we don't need dont_block for the new queue's first poll. + expectDontBlock = false; } } prepareError(); - pollAndFail(async); + pollAndFail(async, expectDontBlock: expectDontBlock); // Still no error reported, even after the same number of iterations // where other errors get reported (as [checkLateReported] checks). check(takeLastReportedError()).isNull(); From 02a2898e970fa067251d54a0e0a04f8d2722febb Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Fri, 4 Jul 2025 18:01:48 +0200 Subject: [PATCH 282/423] l10n: Update translations from Weblate. --- assets/l10n/app_pl.arb | 16 +++++++++++++ assets/l10n/app_ru.arb | 24 +++++++++++++++++++ .../l10n/zulip_localizations_pl.dart | 8 +++---- .../l10n/zulip_localizations_ru.dart | 12 +++++----- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 8aca7248a5..5173de210c 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1196,5 +1196,21 @@ "revealButtonLabel": "Odsłoń wiadomość", "@revealButtonLabel": { "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "emptyMessageListSearch": "Brak wyników wyszukiwania.", + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "searchMessagesPageTitle": "Szukaj", + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "searchMessagesHintText": "Szukaj", + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "searchMessagesClearButtonTooltip": "Wyczyść", + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index a45e84a2f8..ed33099077 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1188,5 +1188,29 @@ "upgradeWelcomeDialogLinkText": "Ознакомьтесь с анонсом в блоге!", "@upgradeWelcomeDialogLinkText": { "description": "Text of link in dialog shown on first upgrade from the legacy Zulip app." + }, + "emptyMessageList": "Здесь нет сообщений.", + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "emptyMessageListSearch": "Ничего не найдено.", + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "searchMessagesPageTitle": "Поиск", + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "searchMessagesHintText": "Поиск", + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "searchMessagesClearButtonTooltip": "Очистить", + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." + }, + "revealButtonLabel": "Показать сообщение", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." } } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 45bcc962d3..0e9cf379b6 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -447,7 +447,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get emptyMessageList => 'Póki co brak wiadomości.'; @override - String get emptyMessageListSearch => 'No search results.'; + String get emptyMessageListSearch => 'Brak wyników wyszukiwania.'; @override String get messageListGroupYouWithYourself => 'Zapiski na własne konto'; @@ -676,13 +676,13 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get userRoleUnknown => 'Nieznany'; @override - String get searchMessagesPageTitle => 'Search'; + String get searchMessagesPageTitle => 'Szukaj'; @override - String get searchMessagesHintText => 'Search'; + String get searchMessagesHintText => 'Szukaj'; @override - String get searchMessagesClearButtonTooltip => 'Clear'; + String get searchMessagesClearButtonTooltip => 'Wyczyść'; @override String get inboxPageTitle => 'Odebrane'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 3a8a567b5d..1349d79baa 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -444,10 +444,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations { } @override - String get emptyMessageList => 'There are no messages here.'; + String get emptyMessageList => 'Здесь нет сообщений.'; @override - String get emptyMessageListSearch => 'No search results.'; + String get emptyMessageListSearch => 'Ничего не найдено.'; @override String get messageListGroupYouWithYourself => 'Сообщения с собой'; @@ -679,13 +679,13 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get userRoleUnknown => 'Неизвестно'; @override - String get searchMessagesPageTitle => 'Search'; + String get searchMessagesPageTitle => 'Поиск'; @override - String get searchMessagesHintText => 'Search'; + String get searchMessagesHintText => 'Поиск'; @override - String get searchMessagesClearButtonTooltip => 'Clear'; + String get searchMessagesClearButtonTooltip => 'Очистить'; @override String get inboxPageTitle => 'Входящие'; @@ -898,7 +898,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get noEarlierMessages => 'Предшествующих сообщений нет'; @override - String get revealButtonLabel => 'Reveal message'; + String get revealButtonLabel => 'Показать сообщение'; @override String get mutedUser => 'Отключенный пользователь'; From 5677317bcef6f44b2e97facfa36fbc319b052326 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 22:04:00 +0530 Subject: [PATCH 283/423] content [nfc]: Remove the `inline` property in _Katex widget And inline the behaviour for `inline: false` in MathBlock widget. --- lib/widgets/content.dart | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5f0171b5a5..bd4ade091f 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -822,7 +822,13 @@ class MathBlock extends StatelessWidget { children: [TextSpan(text: node.texSource)]))); } - return _Katex(inline: false, nodes: nodes); + return Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: SingleChildScrollViewWithScrollbar( + scrollDirection: Axis.horizontal, + child: _Katex( + nodes: nodes)))); } } @@ -835,24 +841,15 @@ const kBaseKatexTextStyle = TextStyle( class _Katex extends StatelessWidget { const _Katex({ - required this.inline, required this.nodes, }); - final bool inline; final List nodes; @override Widget build(BuildContext context) { Widget widget = _KatexNodeList(nodes: nodes); - if (!inline) { - widget = Center( - child: SingleChildScrollViewWithScrollbar( - scrollDirection: Axis.horizontal, - child: widget)); - } - return Directionality( textDirection: TextDirection.ltr, child: DefaultTextStyle( @@ -1274,7 +1271,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(inline: true, nodes: nodes)); + child: _Katex(nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, From b38301fba0bdccb43ccf592493d10475ccfcb9dd Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 7 Jul 2025 14:22:54 +0530 Subject: [PATCH 284/423] content test: Add offset and size based widget tests for KaTeX content And remove font and fontsize based tests, as the newer rect offset/size based tests are more accurate anyway. --- lib/widgets/content.dart | 10 +- test/widgets/content_test.dart | 190 +++++++++++++++++---------------- 2 files changed, 106 insertions(+), 94 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index bd4ade091f..edc11d473c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -827,7 +827,7 @@ class MathBlock extends StatelessWidget { textDirection: TextDirection.ltr, child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: _Katex( + child: KatexWidget( nodes: nodes)))); } } @@ -839,8 +839,10 @@ const kBaseKatexTextStyle = TextStyle( fontFamily: 'KaTeX_Main', height: 1.2); -class _Katex extends StatelessWidget { - const _Katex({ +@visibleForTesting +class KatexWidget extends StatelessWidget { + const KatexWidget({ + super.key, required this.nodes, }); @@ -1271,7 +1273,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(nodes: nodes)); + child: KatexWidget(nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 9ed263fce9..c87668514c 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -572,98 +573,65 @@ void main() { tester.widget(find.text('λ', findRichText: true)); }); - void checkKatexText( - WidgetTester tester, - String text, { - required String fontFamily, - required double fontSize, - required double fontHeight, - }) { - check(mergedStyleOf(tester, text)).isNotNull() - ..fontFamily.equals(fontFamily) - ..fontSize.equals(fontSize); - check(tester.getSize(find.text(text))) - .height.isCloseTo(fontSize * fontHeight, 0.5); - } - - testWidgets('displays KaTeX content with different sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexSizing; - await prepareContent(tester, plainContent(content.html)); - - final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; - final nodes = baseNode.nodes!.skip(1); // Skip .strut node. - for (var katexNode in nodes) { - katexNode = katexNode as KatexSpanNode; - final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; - checkKatexText(tester, katexNode.text!, - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + group('characters render at specific offsets with specific size', () { + const testCases = <(ContentExample, List<(String, Offset, Size)>, {bool? skip})>[ + (ContentExample.mathBlockKatexSizing, skip: false, [ + ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), + ('2', Offset(25.59, 9.90), Size(21.33, 51.00)), + ('3', Offset(46.91, 16.30), Size(17.77, 43.00)), + ('4', Offset(64.68, 21.63), Size(14.80, 36.00)), + ('5', Offset(79.48, 26.07), Size(12.34, 30.00)), + ('6', Offset(91.82, 29.77), Size(10.28, 25.00)), + ('7', Offset(102.10, 31.62), Size(9.25, 22.00)), + ('8', Offset(111.35, 33.47), Size(8.23, 20.00)), + ('9', Offset(119.58, 35.32), Size(7.20, 17.00)), + ('0', Offset(126.77, 39.02), Size(5.14, 12.00)), + ]), + (ContentExample.mathBlockKatexNestedSizing, skip: false, [ + ('1', Offset(0.00, 39.58), Size(5.14, 12.00)), + ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), + ]), + // TODO: Re-enable this test after adding support for parsing + // `vertical-align` in inline styles. Currently it fails + // because `strut` span has `vertical-align`. + (ContentExample.mathBlockKatexDelimSizing, skip: true, [ + ('(', Offset(8.00, 46.36), Size(9.42, 25.00)), + ('[', Offset(17.42, 48.36), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 49.36), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 49.36), Size(13.14, 25.00)), + ]), + ]; + + for (final testCase in testCases) { + testWidgets(testCase.$1.description, (tester) async { + await _loadKatexFonts(); + + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + await prepareContent(tester, plainContent(testCase.$1.html)); + + final baseRect = tester.getRect(find.byType(KatexWidget)); + + for (final characterData in testCase.$2) { + final character = characterData.$1; + final expectedTopLeftOffset = characterData.$2; + final expectedSize = characterData.$3; + + final rect = tester.getRect(find.text(character)); + final topLeftOffset = rect.topLeft - baseRect.topLeft; + final size = rect.size; + + check(topLeftOffset) + .within(distance: 0.05, from: expectedTopLeftOffset); + check(size) + .within(distance: 0.05, from: expectedSize); + } + }, skip: testCase.skip); } }); - - testWidgets('displays KaTeX content with nested sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexNestedSizing; - await prepareContent(tester, plainContent(content.html)); - - var fontSize = 0.5 * kBaseKatexTextStyle.fontSize!; - checkKatexText(tester, '1', - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - - fontSize = 4.976 * fontSize; - checkKatexText(tester, '2', - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - }); - - testWidgets('displays KaTeX content with different delimiter sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexDelimSizing; - await prepareContent(tester, plainContent(content.html)); - - final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; - var nodes = baseNode.nodes!.skip(1); // Skip .strut node. - - final fontSize = kBaseKatexTextStyle.fontSize!; - - final firstNode = nodes.first as KatexSpanNode; - checkKatexText(tester, firstNode.text!, - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - nodes = nodes.skip(1); - - for (var katexNode in nodes) { - 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, - 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], @@ -1432,3 +1400,45 @@ void main() { }); }); } + +Future _loadKatexFonts() async { + const fonts = { + 'KaTeX_AMS': ['KaTeX_AMS-Regular.ttf'], + 'KaTeX_Caligraphic': [ + 'KaTeX_Caligraphic-Regular.ttf', + 'KaTeX_Caligraphic-Bold.ttf', + ], + 'KaTeX_Fraktur': [ + 'KaTeX_Fraktur-Regular.ttf', + 'KaTeX_Fraktur-Bold.ttf', + ], + 'KaTeX_Main': [ + 'KaTeX_Main-Regular.ttf', + 'KaTeX_Main-Bold.ttf', + 'KaTeX_Main-Italic.ttf', + 'KaTeX_Main-BoldItalic.ttf', + ], + 'KaTeX_Math': [ + 'KaTeX_Math-Italic.ttf', + 'KaTeX_Math-BoldItalic.ttf', + ], + 'KaTeX_SansSerif': [ + 'KaTeX_SansSerif-Regular.ttf', + 'KaTeX_SansSerif-Bold.ttf', + 'KaTeX_SansSerif-Italic.ttf', + ], + 'KaTeX_Script': ['KaTeX_Script-Regular.ttf'], + 'KaTeX_Size1': ['KaTeX_Size1-Regular.ttf'], + 'KaTeX_Size2': ['KaTeX_Size2-Regular.ttf'], + 'KaTeX_Size3': ['KaTeX_Size3-Regular.ttf'], + 'KaTeX_Size4': ['KaTeX_Size4-Regular.ttf'], + 'KaTeX_Typewriter': ['KaTeX_Typewriter-Regular.ttf'], + }; + for (final MapEntry(key: fontFamily, value: fontFiles) in fonts.entries) { + final fontLoader = FontLoader(fontFamily); + for (final fontFile in fontFiles) { + fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); + } + await fontLoader.load(); + } +} From bd353ef8bd5f3b5926b76a030d04d0f1f35be444 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 7 Jul 2025 23:45:21 +0530 Subject: [PATCH 285/423] content: Add a workaround for incorrect sizing in WidgetSpan, for KaTeX --- lib/widgets/content.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index edc11d473c..543352e0bb 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -873,9 +873,15 @@ class _KatexNodeList extends StatelessWidget { return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: switch (e) { - KatexSpanNode() => _KatexSpan(e), - }); + // Work around a bug where text inside a WidgetSpan could be scaled + // multiple times incorrectly, if the system font scale is larger + // than 1x. + // See: https://github.com/flutter/flutter/issues/126962 + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.noScaling), + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + })); })))); } } From e8e8f410570712e34dd228b09e7b7b0d0eb10780 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 9 Jul 2025 02:22:19 +0530 Subject: [PATCH 286/423] content: Update base KaTeX text style to be explicit This fixes a bug about `leadingDistribution` where previously it was taking the default value of `TextLeadingDistribution.proportional`, now it uses `TextLeadingDistribution.even` which seems to be the default strategy used by CSS, and it doesn't look like `katex.scss` overrides it. The vertical offsets being updated in tests are because of this fix. Another potential bug fix is about `textBaseline` where on some locale systems the default value for this could be `TextBaseline.ideographic`, and for KaTeX we always want `TextBaseline.alphabetic`. --- lib/widgets/content.dart | 8 +++++++- test/widgets/content_test.dart | 26 +++++++++++++------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 543352e0bb..7f018a5c46 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -837,7 +837,13 @@ class MathBlock extends StatelessWidget { const kBaseKatexTextStyle = TextStyle( fontSize: kBaseFontSize * 1.21, fontFamily: 'KaTeX_Main', - height: 1.2); + height: 1.2, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + decoration: TextDecoration.none, + fontFamilyFallback: []); @visibleForTesting class KatexWidget extends StatelessWidget { diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index c87668514c..d09fc5a989 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -577,18 +577,18 @@ void main() { const testCases = <(ContentExample, List<(String, Offset, Size)>, {bool? skip})>[ (ContentExample.mathBlockKatexSizing, skip: false, [ ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), - ('2', Offset(25.59, 9.90), Size(21.33, 51.00)), - ('3', Offset(46.91, 16.30), Size(17.77, 43.00)), - ('4', Offset(64.68, 21.63), Size(14.80, 36.00)), - ('5', Offset(79.48, 26.07), Size(12.34, 30.00)), - ('6', Offset(91.82, 29.77), Size(10.28, 25.00)), - ('7', Offset(102.10, 31.62), Size(9.25, 22.00)), - ('8', Offset(111.35, 33.47), Size(8.23, 20.00)), - ('9', Offset(119.58, 35.32), Size(7.20, 17.00)), - ('0', Offset(126.77, 39.02), Size(5.14, 12.00)), + ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), + ('3', Offset(46.91, 16.55), Size(17.77, 43.00)), + ('4', Offset(64.68, 21.98), Size(14.80, 36.00)), + ('5', Offset(79.48, 26.50), Size(12.34, 30.00)), + ('6', Offset(91.82, 30.26), Size(10.28, 25.00)), + ('7', Offset(102.10, 32.15), Size(9.25, 22.00)), + ('8', Offset(111.35, 34.03), Size(8.23, 20.00)), + ('9', Offset(119.58, 35.91), Size(7.20, 17.00)), + ('0', Offset(126.77, 39.68), Size(5.14, 12.00)), ]), (ContentExample.mathBlockKatexNestedSizing, skip: false, [ - ('1', Offset(0.00, 39.58), Size(5.14, 12.00)), + ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), ]), // TODO: Re-enable this test after adding support for parsing @@ -596,9 +596,9 @@ void main() { // because `strut` span has `vertical-align`. (ContentExample.mathBlockKatexDelimSizing, skip: true, [ ('(', Offset(8.00, 46.36), Size(9.42, 25.00)), - ('[', Offset(17.42, 48.36), Size(9.71, 25.00)), - ('⌈', Offset(27.12, 49.36), Size(11.99, 25.00)), - ('⌊', Offset(39.11, 49.36), Size(13.14, 25.00)), + ('[', Offset(17.42, 46.36), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 46.36), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 46.36), Size(13.14, 25.00)), ]), ]; From 0ec1641fbbc554abc2962356e1803e06b2da0bca Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 22 Apr 2025 18:01:46 +0530 Subject: [PATCH 287/423] content: Scale inline KaTeX content based on the surrounding text This applies the correct font scaling if the KaTeX content is inside a header. --- lib/widgets/content.dart | 37 +++++++++++++++++++++------------- test/widgets/content_test.dart | 26 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 7f018a5c46..8a06988a12 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -828,30 +828,39 @@ class MathBlock extends StatelessWidget { child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, child: KatexWidget( + textStyle: ContentTheme.of(context).textStylePlainParagraph, nodes: nodes)))); } } -// Base text style from .katex class in katex.scss : -// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 -const kBaseKatexTextStyle = TextStyle( - fontSize: kBaseFontSize * 1.21, - fontFamily: 'KaTeX_Main', - height: 1.2, - fontWeight: FontWeight.normal, - fontStyle: FontStyle.normal, - textBaseline: TextBaseline.alphabetic, - leadingDistribution: TextLeadingDistribution.even, - decoration: TextDecoration.none, - fontFamilyFallback: []); +/// Creates a base text style for rendering KaTeX content. +/// +/// This applies the CSS styles defined in .katex class in katex.scss : +/// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 +/// +/// Requires the [style.fontSize] to be non-null. +TextStyle mkBaseKatexTextStyle(TextStyle style) { + return style.copyWith( + fontSize: style.fontSize! * 1.21, + fontFamily: 'KaTeX_Main', + height: 1.2, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + decoration: TextDecoration.none, + fontFamilyFallback: const []); +} @visibleForTesting class KatexWidget extends StatelessWidget { const KatexWidget({ super.key, + required this.textStyle, required this.nodes, }); + final TextStyle textStyle; final List nodes; @override @@ -861,7 +870,7 @@ class KatexWidget extends StatelessWidget { return Directionality( textDirection: TextDirection.ltr, child: DefaultTextStyle( - style: kBaseKatexTextStyle.copyWith( + style: mkBaseKatexTextStyle(textStyle).copyWith( color: ContentTheme.of(context).textStylePlainParagraph.color), child: widget)); } @@ -1285,7 +1294,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: KatexWidget(nodes: nodes)); + child: KatexWidget(textStyle: widget.style, nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index d09fc5a989..e0e4180c13 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1042,6 +1042,32 @@ void main() { testContentSmoke(ContentExample.mathInline); testWidgets('maintains font-size ratio with surrounding text', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings.getBool(BoolGlobalSetting.renderKatex)).isTrue(); + + const html = '' + 'λ' + ' \\lambda ' + ''; + await checkFontSizeRatio(tester, + targetHtml: html, + targetFontSizeFinder: (rootSpan) { + late final double result; + rootSpan.visitChildren((span) { + if (span case WidgetSpan(child: KatexWidget() && var widget)) { + result = mergedStyleOf(tester, + findAncestor: find.byWidget(widget), r'λ')!.fontSize!; + return false; + } + return true; + }); + return result; + }); + }); + + testWidgets('maintains font-size ratio with surrounding text, when showing TeX source', (tester) async { const html = '' 'λ' ' \\lambda ' From 85f4ecfcd1e4c564aee3da7135adf751625a6d2f Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 29 May 2025 20:00:22 +0530 Subject: [PATCH 288/423] content: Handle 'strut' span in KaTeX content In KaTeX HTML it is used to set the baseline of the content in a span, so handle it separately here. --- lib/model/content.dart | 18 ++++++++++ lib/model/katex.dart | 64 ++++++++++++++++++++++++++++++++-- lib/widgets/content.dart | 29 +++++++++++++++ test/model/content_test.dart | 36 +++++++------------ test/widgets/content_test.dart | 15 ++++---- 5 files changed, 127 insertions(+), 35 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index e80247325f..78fc961c00 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -411,6 +411,24 @@ class KatexSpanNode extends KatexNode { } } +class KatexStrutNode extends KatexNode { + const KatexStrutNode({ + required this.heightEm, + required this.verticalAlignEm, + super.debugHtmlNode, + }); + + final double heightEm; + final double? verticalAlignEm; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('heightEm', heightEm)); + properties.add(DoubleProperty('verticalAlignEm', verticalAlignEm)); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 922546c676..f896bbdc86 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -187,7 +187,34 @@ class _KatexParser { final debugHtmlNode = kDebugMode ? element : null; + if (element.className == 'strut') { + if (element.nodes.isNotEmpty) throw _KatexHtmlParseError(); + + final styles = _parseSpanInlineStyles(element); + if (styles == null) throw _KatexHtmlParseError(); + + final heightEm = styles.heightEm; + if (heightEm == null) throw _KatexHtmlParseError(); + final verticalAlignEm = styles.verticalAlignEm; + + // Ensure only `height` and `vertical-align` inline styles are present. + if (styles.filter(heightEm: false, verticalAlignEm: false) + != const KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } + + return KatexStrutNode( + heightEm: heightEm, + verticalAlignEm: verticalAlignEm, + debugHtmlNode: debugHtmlNode); + } + final inlineStyles = _parseSpanInlineStyles(element); + if (inlineStyles != null) { + // We expect `vertical-align` inline style to be only present on a + // `strut` span, for which we emit `KatexStrutNode` separately. + if (inlineStyles.verticalAlignEm != null) throw _KatexHtmlParseError(); + } // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. @@ -214,8 +241,9 @@ class _KatexParser { case 'strut': // .strut { ... } - // Do nothing, it has properties that don't need special handling. - break; + // We expect the 'strut' class to be the only class in a span, + // in which case we handle it separately and emit `KatexStrutNode`. + throw _KatexHtmlParseError(); case 'textbf': // .textbf { font-weight: bold; } @@ -463,6 +491,7 @@ class _KatexParser { final stylesheet = css_parser.parse('*{$styleStr}'); if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; + double? verticalAlignEm; for (final declaration in rule.declarationGroup.declarations) { if (declaration case css_visitor.Declaration( @@ -474,6 +503,10 @@ class _KatexParser { case 'height': heightEm = _getEm(expression); if (heightEm != null) continue; + + case 'vertical-align': + verticalAlignEm = _getEm(expression); + if (verticalAlignEm != null) continue; } // TODO handle more CSS properties @@ -488,6 +521,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + verticalAlignEm: verticalAlignEm, ); } else { throw _KatexHtmlParseError(); @@ -524,6 +558,7 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { final double? heightEm; + final double? verticalAlignEm; final String? fontFamily; final double? fontSizeEm; @@ -533,6 +568,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, + this.verticalAlignEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -544,6 +580,7 @@ class KatexSpanStyles { int get hashCode => Object.hash( 'KatexSpanStyles', heightEm, + verticalAlignEm, fontFamily, fontSizeEm, fontWeight, @@ -555,6 +592,7 @@ class KatexSpanStyles { bool operator ==(Object other) { return other is KatexSpanStyles && other.heightEm == heightEm && + other.verticalAlignEm == verticalAlignEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -566,6 +604,7 @@ class KatexSpanStyles { String toString() { final args = []; if (heightEm != null) args.add('heightEm: $heightEm'); + if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -584,6 +623,7 @@ class KatexSpanStyles { KatexSpanStyles merge(KatexSpanStyles other) { return KatexSpanStyles( heightEm: other.heightEm ?? heightEm, + verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, fontFamily: other.fontFamily ?? fontFamily, fontSizeEm: other.fontSizeEm ?? fontSizeEm, fontStyle: other.fontStyle ?? fontStyle, @@ -591,6 +631,26 @@ class KatexSpanStyles { textAlign: other.textAlign ?? textAlign, ); } + + KatexSpanStyles filter({ + bool heightEm = true, + bool verticalAlignEm = true, + bool fontFamily = true, + bool fontSizeEm = true, + bool fontWeight = true, + bool fontStyle = true, + bool textAlign = true, + }) { + return KatexSpanStyles( + heightEm: heightEm ? this.heightEm : null, + verticalAlignEm: verticalAlignEm ? this.verticalAlignEm : null, + fontFamily: fontFamily ? this.fontFamily : null, + fontSizeEm: fontSizeEm ? this.fontSizeEm : null, + fontWeight: fontWeight ? this.fontWeight : null, + fontStyle: fontStyle ? this.fontStyle : null, + textAlign: textAlign ? this.textAlign : null, + ); + } } class _KatexHtmlParseError extends Error { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 8a06988a12..8cdb1e79ec 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -896,6 +896,7 @@ class _KatexNodeList extends StatelessWidget { data: MediaQueryData(textScaler: TextScaler.noScaling), child: switch (e) { KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), })); })))); } @@ -918,6 +919,10 @@ class _KatexSpan extends StatelessWidget { } final styles = node.styles; + // We expect vertical-align to be only present with the + // `strut` span, for which parser explicitly emits `KatexStrutNode`. + // So, this should always be null for non `strut` spans. + assert(styles.verticalAlignEm == null); final fontFamily = styles.fontFamily; final fontSize = switch (styles.fontSizeEm) { @@ -976,6 +981,30 @@ class _KatexSpan extends StatelessWidget { } } +class _KatexStrut extends StatelessWidget { + const _KatexStrut(this.node); + + final KatexStrutNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + final verticalAlignEm = node.verticalAlignEm; + if (verticalAlignEm == null) { + return SizedBox(height: node.heightEm * em); + } + + return SizedBox( + height: node.heightEm * em, + child: Baseline( + baseline: (verticalAlignEm + node.heightEm) * em, + baselineType: TextBaseline.alphabetic, + child: const Text('')), + ); + } +} + class WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index f9b461e17c..652a6f10b8 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(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), 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(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), 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(heightEm: 0.4306), text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), 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(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), 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(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), 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(heightEm: 0.4306), text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), 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(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), 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(heightEm: 0.4306),text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -731,10 +731,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 1.6034), - text: null, - nodes: []), + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 text: '1', @@ -800,10 +797,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 1.6034), - text: null, - nodes: []), + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 text: null, @@ -845,10 +839,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 3.0), - text: null, - nodes: []), + KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), KatexSpanNode( styles: KatexSpanStyles(), text: '⟨', @@ -1981,10 +1972,7 @@ void main() async { testParseExample(ContentExample.mathBlockBetweenImages); testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); - // 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.mathBlockKatexDelimSizing); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index e0e4180c13..7657a80c3f 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -591,14 +591,11 @@ void main() { ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), ]), - // TODO: Re-enable this test after adding support for parsing - // `vertical-align` in inline styles. Currently it fails - // because `strut` span has `vertical-align`. - (ContentExample.mathBlockKatexDelimSizing, skip: true, [ - ('(', Offset(8.00, 46.36), Size(9.42, 25.00)), - ('[', Offset(17.42, 46.36), Size(9.71, 25.00)), - ('⌈', Offset(27.12, 46.36), Size(11.99, 25.00)), - ('⌊', Offset(39.11, 46.36), Size(13.14, 25.00)), + (ContentExample.mathBlockKatexDelimSizing, skip: false, [ + ('(', Offset(8.00, 20.14), Size(9.42, 25.00)), + ('[', Offset(17.42, 20.14), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), ]), ]; @@ -629,7 +626,7 @@ void main() { check(size) .within(distance: 0.05, from: expectedSize); } - }, skip: testCase.skip); + }); } }); }); From 1fe817cfce109c8e74339a5ddae10ab552df9e59 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:46:15 +0530 Subject: [PATCH 289/423] content: Handle positive margin-right and margin-left in KaTeX spans --- lib/model/katex.dart | 35 ++++++++++++++++++++ lib/widgets/content.dart | 25 ++++++++++++-- test/model/content_test.dart | 60 ++++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 5 +++ 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index f896bbdc86..057f7076bc 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -492,6 +492,8 @@ class _KatexParser { if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; double? verticalAlignEm; + double? marginRightEm; + double? marginLeftEm; for (final declaration in rule.declarationGroup.declarations) { if (declaration case css_visitor.Declaration( @@ -507,6 +509,20 @@ class _KatexParser { case 'vertical-align': verticalAlignEm = _getEm(expression); if (verticalAlignEm != null) continue; + + case 'margin-right': + marginRightEm = _getEm(expression); + if (marginRightEm != null) { + if (marginRightEm < 0) throw _KatexHtmlParseError(); + continue; + } + + case 'margin-left': + marginLeftEm = _getEm(expression); + if (marginLeftEm != null) { + if (marginLeftEm < 0) throw _KatexHtmlParseError(); + continue; + } } // TODO handle more CSS properties @@ -522,6 +538,8 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, verticalAlignEm: verticalAlignEm, + marginRightEm: marginRightEm, + marginLeftEm: marginLeftEm, ); } else { throw _KatexHtmlParseError(); @@ -560,6 +578,9 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? marginRightEm; + final double? marginLeftEm; + final String? fontFamily; final double? fontSizeEm; final KatexSpanFontWeight? fontWeight; @@ -569,6 +590,8 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.marginRightEm, + this.marginLeftEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -581,6 +604,8 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + marginRightEm, + marginLeftEm, fontFamily, fontSizeEm, fontWeight, @@ -593,6 +618,8 @@ class KatexSpanStyles { return other is KatexSpanStyles && other.heightEm == heightEm && other.verticalAlignEm == verticalAlignEm && + other.marginRightEm == marginRightEm && + other.marginLeftEm == marginLeftEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -605,6 +632,8 @@ class KatexSpanStyles { final args = []; if (heightEm != null) args.add('heightEm: $heightEm'); if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); + if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); + if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -624,6 +653,8 @@ class KatexSpanStyles { return KatexSpanStyles( heightEm: other.heightEm ?? heightEm, verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, + marginRightEm: other.marginRightEm ?? marginRightEm, + marginLeftEm: other.marginLeftEm ?? marginLeftEm, fontFamily: other.fontFamily ?? fontFamily, fontSizeEm: other.fontSizeEm ?? fontSizeEm, fontStyle: other.fontStyle ?? fontStyle, @@ -635,6 +666,8 @@ class KatexSpanStyles { KatexSpanStyles filter({ bool heightEm = true, bool verticalAlignEm = true, + bool marginRightEm = true, + bool marginLeftEm = true, bool fontFamily = true, bool fontSizeEm = true, bool fontWeight = true, @@ -644,6 +677,8 @@ class KatexSpanStyles { return KatexSpanStyles( heightEm: heightEm ? this.heightEm : null, verticalAlignEm: verticalAlignEm ? this.verticalAlignEm : null, + marginRightEm: marginRightEm ? this.marginRightEm : null, + marginLeftEm: marginLeftEm ? this.marginLeftEm : null, fontFamily: fontFamily ? this.fontFamily : null, fontSizeEm: fontSizeEm ? this.fontSizeEm : null, fontWeight: fontWeight ? this.fontWeight : null, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 8cdb1e79ec..52ab7008b8 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -909,7 +909,7 @@ class _KatexSpan extends StatelessWidget { @override Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; + var em = DefaultTextStyle.of(context).style.fontSize!; Widget widget = const SizedBox.shrink(); if (node.text != null) { @@ -929,6 +929,8 @@ class _KatexSpan extends StatelessWidget { double fontSizeEm => fontSizeEm * em, null => null, }; + if (fontSize != null) em = fontSize; + final fontWeight = switch (styles.fontWeight) { KatexSpanFontWeight.bold => FontWeight.bold, null => null, @@ -973,11 +975,28 @@ class _KatexSpan extends StatelessWidget { child: widget); } - return SizedBox( + widget = SizedBox( height: styles.heightEm != null - ? styles.heightEm! * (fontSize ?? em) + ? styles.heightEm! * em : null, child: widget); + + final margin = switch ((styles.marginLeftEm, styles.marginRightEm)) { + (null, null) => null, + (null, final marginRightEm?) => + EdgeInsets.only(right: marginRightEm * em), + (final marginLeftEm?, null) => + EdgeInsets.only(left: marginLeftEm * em), + (final marginLeftEm?, final marginRightEm?) => + EdgeInsets.only(left: marginLeftEm * em, right: marginRightEm * em), + }; + + if (margin != null) { + assert(margin.isNonNegative); + widget = Padding(padding: margin, child: widget); + } + + return widget; } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 652a6f10b8..c19e1c02a5 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -884,6 +884,65 @@ class ContentExample { ]), ]); + static const mathBlockKatexSpace = ContentExample( + 'math block; KaTeX space', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2214883 + '```math\n1:2\n```', + '

' + '' + '1:21:2' + '

', [ + MathBlockNode( + texSource: '1:2', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode( + heightEm: 0.6444, + verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(), + text: '1', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + text: null, + nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(), + text: ':', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + text: null, + nodes: []), + ]), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode( + heightEm: 0.6444, + verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(), + text: '2', + nodes: null), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -1973,6 +2032,7 @@ void main() async { testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); testParseExample(ContentExample.mathBlockKatexDelimSizing); + testParseExample(ContentExample.mathBlockKatexSpace); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 7657a80c3f..f6366a9215 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -597,6 +597,11 @@ void main() { ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), ]), + (ContentExample.mathBlockKatexSpace, skip: false, [ + ('1', Offset(0.00, 2.24), Size(10.28, 25.00)), + (':', Offset(16.00, 2.24), Size(5.72, 25.00)), + ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), + ]), ]; for (final testCase in testCases) { From 94037d7c1585b75b9681053f9b93400cfc362c4a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 9 Jul 2025 20:46:14 -0700 Subject: [PATCH 290/423] version: Sync version and changelog from v30.0.261 release --- docs/changelog.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index bcd4aa003b..fd2adb2da6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,60 @@ ## Unreleased +## 30.0.261 (2025-07-09) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +* See who reacted to a message. (#740) +* Turn invisible mode on and off. (#1578) +* Less empty space at end of message feed. (PR #1628) +* After you return to the app, it resumes its connection + more quickly. (#979) +* The message long-press menu shows the message and + when it was sent. (#217) +* (iOS) Fixed white flash on opening app in dark mode. (#1149) + + +### Highlights for developers + +* User-visible changes not described above: + * Upgraded Flutter and other dependencies. (#1684) + * Case-insensitive topics in unreads and other data + structures. (#980) + * Icon for topic-list button, rather than "TOPICS". (#1532) + * Status emoji properly follow system text-scale setting. + (revision to PR #1629, for #197) + * Status text's font size increased. + (revision to PR #1629, for #197) + * Fixed scroll behavior of math blocks in RTL locales. + (revision to PR #1452, at 5677317bc, for #46) + * Fixed vertical alignment within TeX math expressions. + (e8e8f4105; revision to PR #1452, for #46) + * Adjusted color of icons in action sheets. + (included in PR #1631, for #1578) + * Removed blank space for absent status emoji. + (revision to PR #1629, for #197) + * Adjusted choice of "Close" vs "Cancel" in action sheets. + (included in PR #1700, for #740) + * Translation updates. (PR #1682) + +* Workarounds in our CI for a Flutter infra issue with the + "main" branch. (PR #1690, PR #1691; flutter/flutter#171833) + +* Resolved in main: #296, PR #1684, PR #1628, #980, #1532, #662, + #217, #1578, #1149, PR #1629, #979, PR #1682, PR #1452 + +* Resolved in the experimental branch: + * more toward #46 via PR #1698 + * further toward #46 via PR #1559 + * #197 via PR #1702 + * #740 via PR #1700 + + ## 30.0.260 (2025-07-03) This release branch includes some experimental changes diff --git a/pubspec.yaml b/pubspec.yaml index a4bc1d69e2..a87dc4a4bd 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.260+260 +version: 30.0.261+261 environment: # We use a recent version of Flutter from its main channel, and From d6affc9dbbb492d20af9e8721319ac57bf2238d7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 15:49:11 -0700 Subject: [PATCH 291/423] ci: Fetch 3000 commits from upstream, rather than 1000 Fixes #1710. This should work around the problem for a few more months. If it doesn't get fixed upstream by then, we can figure something else out. --- .github/workflows/ci.yml | 6 +++++- .github/workflows/update-translations.yml | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7820b3e2de..89d71fb8ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,13 +22,17 @@ jobs: # so that Flutter knows its version and sees the constraint in our # pubspec is satisfied. It's uncommon for flutter/flutter to go # more than 100 commits between tags. Fetch 1000 for good measure. + # TODO(upstream): Around 2025-05, Flutter upstream stopped making + # tags within the main/master branch. Get that fixed: + # https://github.com/zulip/zulip-flutter/issues/1710 + # Pending that, fetch more than 1000 commits. run: | # TODO temp hack 2025-07-08 as Flutter's `main` is broken but `master` works: # https://github.com/zulip/zulip-flutter/pull/1688#issuecomment-3050661097 # https://discord.com/channels/608014603317936148/608021351567065092/1392301750383415376 # https://github.com/flutter/flutter/issues/171833 # (See also "temp hack" items below.) - git clone --depth=1000 -b master https://github.com/flutter/flutter ~/flutter + git clone --depth=3000 -b master https://github.com/flutter/flutter ~/flutter TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local echo ~/flutter/bin >> "$GITHUB_PATH" diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index fb676042ff..7703fd6ec1 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -29,8 +29,9 @@ jobs: # so that Flutter knows its version and sees the constraint in our # pubspec is satisfied. It's uncommon for flutter/flutter to go # more than 100 commits between tags. Fetch 1000 for good measure. + # TODO(upstream): See ci.yml for why we fetch more than 1000. run: | - git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter + git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local echo ~/flutter/bin >> "$GITHUB_PATH" From a3bbee9f037e5f8c6d10514b8a296236270f93c2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 15:46:02 -0700 Subject: [PATCH 292/423] ci: Revert temp hack for desync of Flutter main branch This reverts commits 9cc34cd7e and 37af86db6 (#1691, #1690). The operational issue in the upstream repo which this worked around has been fixed. --- .github/workflows/ci.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89d71fb8ec..4ff2bfbe7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,7 @@ jobs: # https://github.com/zulip/zulip-flutter/issues/1710 # Pending that, fetch more than 1000 commits. run: | - # TODO temp hack 2025-07-08 as Flutter's `main` is broken but `master` works: - # https://github.com/zulip/zulip-flutter/pull/1688#issuecomment-3050661097 - # https://discord.com/channels/608014603317936148/608021351567065092/1392301750383415376 - # https://github.com/flutter/flutter/issues/171833 - # (See also "temp hack" items below.) - git clone --depth=3000 -b master https://github.com/flutter/flutter ~/flutter + git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local echo ~/flutter/bin >> "$GITHUB_PATH" @@ -40,7 +35,7 @@ jobs: # (or "upstream/master"): # https://github.com/flutter/flutter/issues/160626 # TODO(upstream): make workaround unneeded - # TODO, see temp hack above: git --git-dir ~/flutter/.git update-ref refs/remotes/origin/master origin/main + git --git-dir ~/flutter/.git update-ref refs/remotes/origin/master origin/main - name: Download Flutter SDK artifacts (flutter precache) run: flutter precache --universal @@ -49,5 +44,4 @@ jobs: run: flutter pub get - name: Run tools/check - # TODO omitting flutter_version, see temp hack above - run: TERM=dumb tools/check --verbose --all-files analyze test build_runner l10n drift pigeon icons android shellcheck + run: TERM=dumb tools/check --all --verbose From a04b44ede02baf4a62b538d876eee7fdfa21f8a4 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 17:39:30 -0700 Subject: [PATCH 293/423] msglist test: Fix a state leak from mutating eg.selfUser Fixes #1712. Our CI has been failing in this test file since yesterday; this change fixes that. This test had been having the store handle a RealmUserUpdateEvent affecting `eg.selfUser`. That means the store mutates the actual User object it has... which, in this test context, means the value that `eg.selfUser` is bound to as a final variable. As a result, when the test gets cleaned up with testBinding.reset, the store gets discarded as usual so that the next test will get a fresh store... but the User object at `eg.selfUser` is still the one that's been mutated by this test. That's buggy in principle. Concretely, here, it causes the self-user to have a non-null avatar. When a later test sends a message and causes an outbox-message to appear in the tree, that results in a NetworkImage. And that throws an error, because no HttpClient provider has been set, because that latter test wasn't expecting to create any `NetworkImage`s. That's the failure we've been seeing in CI. It's still a bit mysterious why this had previously been working (or anyway these tests hadn't been failing). It started failing with an upstream change merged yesterday, which makes changes to NetworkImage that look innocuous: its `==` and `hashCode` become finer-grained, and its `toString` more detailed. In any case, the bug is ours to fix. It'd also be good to follow up here by systematically preventing this sort of state leak between tests. But that comes after getting our CI passing again. --- test/widgets/message_list_test.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index d068e12ed5..8559508658 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1693,15 +1693,18 @@ void main() { } } + final user = eg.user(); + Future handleNewAvatarEventAndPump(WidgetTester tester, String avatarUrl) async { - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, avatarUrl: avatarUrl)); + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: user.userId, avatarUrl: avatarUrl)); await tester.pump(); } prepareBoringImageHttpClient(); - await setupMessageListPage(tester, messageCount: 10); - checkResultForSender(eg.selfUser.avatarUrl); + await setupMessageListPage(tester, users: [user], + messages: [eg.streamMessage(sender: user)]); + checkResultForSender(user.avatarUrl); await handleNewAvatarEventAndPump(tester, '/foo.png'); checkResultForSender('/foo.png'); From 16bec6a7ea7731af22dafc83772cc1642842c79b Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:54:24 +0530 Subject: [PATCH 294/423] content: Handle vertical offset spans in KaTeX content Implement handling most common types of `vlist` spans. --- lib/model/content.dart | 31 +++++ lib/model/katex.dart | 107 +++++++++++++++- lib/widgets/content.dart | 22 ++++ test/model/content_test.dart | 226 +++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 17 +++ 5 files changed, 402 insertions(+), 1 deletion(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 78fc961c00..ddb4a785a2 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -429,6 +429,37 @@ class KatexStrutNode extends KatexNode { } } +class KatexVlistNode extends KatexNode { + const KatexVlistNode({ + required this.rows, + super.debugHtmlNode, + }); + + final List rows; + + @override + List debugDescribeChildren() { + return rows.map((row) => row.toDiagnosticsNode()).toList(); + } +} + +class KatexVlistRowNode extends ContentNode { + const KatexVlistRowNode({ + required this.verticalOffsetEm, + required this.node, + super.debugHtmlNode, + }); + + final double verticalOffsetEm; + final KatexSpanNode node; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('verticalOffsetEm', verticalOffsetEm)); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 057f7076bc..501955cff7 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -209,11 +209,99 @@ class _KatexParser { debugHtmlNode: debugHtmlNode); } + if (element.className == 'vlist-t' + || element.className == 'vlist-t vlist-t2') { + final vlistT = element; + if (vlistT.nodes.isEmpty) throw _KatexHtmlParseError(); + if (vlistT.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + final hasTwoVlistR = vlistT.className == 'vlist-t vlist-t2'; + if (!hasTwoVlistR && vlistT.nodes.length != 1) throw _KatexHtmlParseError(); + + if (hasTwoVlistR) { + if (vlistT.nodes case [ + _, + dom.Element(localName: 'span', className: 'vlist-r', nodes: [ + dom.Element(localName: 'span', className: 'vlist', nodes: [ + dom.Element(localName: 'span', className: '', nodes: []), + ]), + ]), + ]) { + // Do nothing. + } else { + throw _KatexHtmlParseError(); + } + } + + if (vlistT.nodes.first + case dom.Element(localName: 'span', className: 'vlist-r') && + final vlistR) { + if (vlistR.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + if (vlistR.nodes.first + case dom.Element(localName: 'span', className: 'vlist') && + final vlist) { + final rows = []; + + for (final innerSpan in vlist.nodes) { + if (innerSpan case dom.Element( + localName: 'span', + className: '', + nodes: [ + dom.Element(localName: 'span', className: 'pstrut') && + final pstrutSpan, + ...final otherSpans, + ], + )) { + var styles = _parseSpanInlineStyles(innerSpan); + if (styles == null) throw _KatexHtmlParseError(); + if (styles.verticalAlignEm != null) throw _KatexHtmlParseError(); + final topEm = styles.topEm ?? 0; + + styles = styles.filter(topEm: false); + + final pstrutStyles = _parseSpanInlineStyles(pstrutSpan); + if (pstrutStyles == null) throw _KatexHtmlParseError(); + if (pstrutStyles.filter(heightEm: false) + != const KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } + final pstrutHeight = pstrutStyles.heightEm ?? 0; + + rows.add(KatexVlistRowNode( + verticalOffsetEm: topEm + pstrutHeight, + debugHtmlNode: kDebugMode ? innerSpan : null, + node: KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)))); + } else { + throw _KatexHtmlParseError(); + } + } + + // TODO(#1716) Handle styling for .vlist-t2 spans + return KatexVlistNode( + rows: rows, + debugHtmlNode: debugHtmlNode, + ); + } else { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } + final inlineStyles = _parseSpanInlineStyles(element); if (inlineStyles != null) { // We expect `vertical-align` inline style to be only present on a // `strut` span, for which we emit `KatexStrutNode` separately. if (inlineStyles.verticalAlignEm != null) throw _KatexHtmlParseError(); + + // Currently, we expect `top` to only be inside a vlist, and + // we handle that case separately above. + if (inlineStyles.topEm != null) throw _KatexHtmlParseError(); } // Aggregate the CSS styles that apply, in the same order as the CSS @@ -224,7 +312,9 @@ class _KatexParser { // https://github.com/KaTeX/KaTeX/blob/2fe1941b/src/styles/katex.scss // A copy of class definition (where possible) is accompanied in a comment // with each case statement to keep track of updates. - final spanClasses = List.unmodifiable(element.className.split(' ')); + final spanClasses = element.className != '' + ? List.unmodifiable(element.className.split(' ')) + : const []; String? fontFamily; double? fontSizeEm; KatexSpanFontWeight? fontWeight; @@ -492,6 +582,7 @@ class _KatexParser { if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; double? verticalAlignEm; + double? topEm; double? marginRightEm; double? marginLeftEm; @@ -510,6 +601,10 @@ class _KatexParser { verticalAlignEm = _getEm(expression); if (verticalAlignEm != null) continue; + case 'top': + topEm = _getEm(expression); + if (topEm != null) continue; + case 'margin-right': marginRightEm = _getEm(expression); if (marginRightEm != null) { @@ -537,6 +632,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + topEm: topEm, verticalAlignEm: verticalAlignEm, marginRightEm: marginRightEm, marginLeftEm: marginLeftEm, @@ -578,6 +674,8 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? topEm; + final double? marginRightEm; final double? marginLeftEm; @@ -590,6 +688,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.topEm, this.marginRightEm, this.marginLeftEm, this.fontFamily, @@ -604,6 +703,7 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + topEm, marginRightEm, marginLeftEm, fontFamily, @@ -618,6 +718,7 @@ class KatexSpanStyles { return other is KatexSpanStyles && other.heightEm == heightEm && other.verticalAlignEm == verticalAlignEm && + other.topEm == topEm && other.marginRightEm == marginRightEm && other.marginLeftEm == marginLeftEm && other.fontFamily == fontFamily && @@ -632,6 +733,7 @@ class KatexSpanStyles { final args = []; if (heightEm != null) args.add('heightEm: $heightEm'); if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); + if (topEm != null) args.add('topEm: $topEm'); if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); @@ -653,6 +755,7 @@ class KatexSpanStyles { return KatexSpanStyles( heightEm: other.heightEm ?? heightEm, verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, + topEm: other.topEm ?? topEm, marginRightEm: other.marginRightEm ?? marginRightEm, marginLeftEm: other.marginLeftEm ?? marginLeftEm, fontFamily: other.fontFamily ?? fontFamily, @@ -666,6 +769,7 @@ class KatexSpanStyles { KatexSpanStyles filter({ bool heightEm = true, bool verticalAlignEm = true, + bool topEm = true, bool marginRightEm = true, bool marginLeftEm = true, bool fontFamily = true, @@ -677,6 +781,7 @@ class KatexSpanStyles { return KatexSpanStyles( heightEm: heightEm ? this.heightEm : null, verticalAlignEm: verticalAlignEm ? this.verticalAlignEm : null, + topEm: topEm ? this.topEm : null, marginRightEm: marginRightEm ? this.marginRightEm : null, marginLeftEm: marginLeftEm ? this.marginLeftEm : null, fontFamily: fontFamily ? this.fontFamily : null, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 52ab7008b8..ba05f5205e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -897,6 +897,7 @@ class _KatexNodeList extends StatelessWidget { child: switch (e) { KatexSpanNode() => _KatexSpan(e), KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), })); })))); } @@ -924,6 +925,10 @@ class _KatexSpan extends StatelessWidget { // So, this should always be null for non `strut` spans. assert(styles.verticalAlignEm == null); + // Currently, we expect `top` to be only present with the + // vlist inner row span, and parser handles that explicitly. + assert(styles.topEm == null); + final fontFamily = styles.fontFamily; final fontSize = switch (styles.fontSizeEm) { double fontSizeEm => fontSizeEm * em, @@ -1024,6 +1029,23 @@ class _KatexStrut extends StatelessWidget { } } +class _KatexVlist extends StatelessWidget { + const _KatexVlist(this.node); + + final KatexVlistNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return Stack(children: List.unmodifiable(node.rows.map((row) { + return Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: _KatexSpan(row.node)); + }))); + } +} + class WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index c19e1c02a5..93e94b5f85 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -943,6 +943,228 @@ class ContentExample { ]), ]); + static const mathBlockKatexSuperscript = ContentExample( + 'math block, KaTeX superscript; single vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 + '```math\na\'\n```', + '

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

', [ + MathBlockNode(texSource: 'a\'', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: '′', nodes: null), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexSubscript = ContentExample( + 'math block, KaTeX subscript; two vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 + '```math\nx_n\n```', + '

' + '' + 'xn' + 'x_n' + '

', [ + MathBlockNode(texSource: 'x_n', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'x', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n', nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexSubSuperScript = ContentExample( + 'math block, KaTeX subsup script; two vlist-r, multiple vertical offset rows', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 + '```math\n_u^o\n```', + '

' + '' + 'uo' + '_u^o' + '

', [ + MathBlockNode(texSource: "_u^o", nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.453 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'u', nodes: null), + ]), + ])), + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'o', nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexRaisebox = ContentExample( + 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 + '```math\na\\raisebox{0.25em}{\$b\$}c\n```', + '

' + '' + 'abc' + 'a\\raisebox{0.25em}{\$b\$}c' + '

', [ + MathBlockNode(texSource: 'a\\raisebox{0.25em}{\$b\$}c', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a', nodes: null), + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.25 + 3, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'b', nodes: null), + ]), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'c', nodes: null), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2033,6 +2255,10 @@ void main() async { testParseExample(ContentExample.mathBlockKatexNestedSizing); testParseExample(ContentExample.mathBlockKatexDelimSizing); testParseExample(ContentExample.mathBlockKatexSpace); + testParseExample(ContentExample.mathBlockKatexSuperscript); + testParseExample(ContentExample.mathBlockKatexSubscript); + testParseExample(ContentExample.mathBlockKatexSubSuperScript); + testParseExample(ContentExample.mathBlockKatexRaisebox); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index f6366a9215..7cc249d79b 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -602,6 +602,23 @@ void main() { (':', Offset(16.00, 2.24), Size(5.72, 25.00)), ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), ]), + (ContentExample.mathBlockKatexSuperscript, skip: false, [ + ('a', Offset(0.00, 5.28), Size(10.88, 25.00)), + ('′', Offset(10.88, 1.13), Size(3.96, 17.00)), + ]), + (ContentExample.mathBlockKatexSubscript, skip: false, [ + ('x', Offset(0.00, 5.28), Size(11.76, 25.00)), + ('n', Offset(11.76, 13.65), Size(8.63, 17.00)), + ]), + (ContentExample.mathBlockKatexSubSuperScript, skip: false, [ + ('u', Offset(0.00, 15.65), Size(8.23, 17.00)), + ('o', Offset(0.00, 2.07), Size(6.98, 17.00)), + ]), + (ContentExample.mathBlockKatexRaisebox, skip: false, [ + ('a', Offset(0.00, 4.16), Size(10.88, 25.00)), + ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), + ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), + ]), ]; for (final testCase in testCases) { From 967a498f3ad4c60e4e67f345a109c651bb9b2869 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 20 Jun 2025 21:04:39 +0530 Subject: [PATCH 295/423] content: Error message for unexpected CSS class in vlist inner span --- lib/model/katex.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 501955cff7..2652e89523 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -246,13 +246,17 @@ class _KatexParser { for (final innerSpan in vlist.nodes) { if (innerSpan case dom.Element( localName: 'span', - className: '', nodes: [ dom.Element(localName: 'span', className: 'pstrut') && final pstrutSpan, ...final otherSpans, ], )) { + if (innerSpan.className != '') { + throw _KatexHtmlParseError('unexpected CSS class for ' + 'vlist inner span: ${innerSpan.className}'); + } + var styles = _parseSpanInlineStyles(innerSpan); if (styles == null) throw _KatexHtmlParseError(); if (styles.verticalAlignEm != null) throw _KatexHtmlParseError(); From 74b08db3aec351b8b74d59b2919e6c0a95c6c9c3 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 17 Jul 2025 05:34:26 +0530 Subject: [PATCH 296/423] content: Implement debugDescribeChildren for KatexVlistRowNode Turns out that anything under KatexVlistRowNode wasn't being tested by content tests, fix that by implementing this method. Fortunately there were no fixes needed in the tests. --- lib/model/content.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/model/content.dart b/lib/model/content.dart index ddb4a785a2..9f906d1c4c 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -458,6 +458,11 @@ class KatexVlistRowNode extends ContentNode { super.debugFillProperties(properties); properties.add(DoubleProperty('verticalOffsetEm', verticalOffsetEm)); } + + @override + List debugDescribeChildren() { + return [node.toDiagnosticsNode()]; + } } class MathBlockNode extends MathNode implements BlockContentNode { From c4503b492adbb9c69f0957fd8c277d5e878f444e Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 17 Jul 2025 19:49:19 +0530 Subject: [PATCH 297/423] content: Make sure there aren't any unexpected styles on `.vlist` span Also add a comment explaining the reason of ignoring the `height` inline styles value. --- lib/model/katex.dart | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 2652e89523..8fe55dde77 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -224,10 +224,18 @@ class _KatexParser { dom.Element(localName: 'span', className: 'vlist-r', nodes: [ dom.Element(localName: 'span', className: 'vlist', nodes: [ dom.Element(localName: 'span', className: '', nodes: []), - ]), + ]) && final vlist, ]), ]) { - // Do nothing. + // In the generated HTML the .vlist in second .vlist-r span will have + // a "height" inline style which we ignore, because it doesn't seem + // to have any effect in rendering on the web. + // But also make sure there aren't any other inline styles present. + final vlistStyles = _parseSpanInlineStyles(vlist); + if (vlistStyles != null + && vlistStyles.filter(heightEm: false) != const KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } } else { throw _KatexHtmlParseError(); } @@ -241,6 +249,17 @@ class _KatexParser { if (vlistR.nodes.first case dom.Element(localName: 'span', className: 'vlist') && final vlist) { + // Same as above for the second .vlist-r span, .vlist span in first + // .vlist-r span will have "height" inline style which we ignore, + // because it doesn't seem to have any effect in rendering on + // the web. + // But also make sure there aren't any other inline styles present. + final vlistStyles = _parseSpanInlineStyles(vlist); + if (vlistStyles != null + && vlistStyles.filter(heightEm: false) != const KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } + final rows = []; for (final innerSpan in vlist.nodes) { From 0507ac1941a517c725111e38332c09e17e734d48 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Sat, 19 Jul 2025 02:12:39 +0530 Subject: [PATCH 298/423] narrow: Introduce TopicNarrow.processTopicLikeServer This is a helper function around `TopicName.processLikeServer`, where it creates a new `TopicNarrow` with the processed topic name. --- lib/model/narrow.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index ff4ccfbbc0..e8c667ef31 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -108,6 +108,24 @@ class TopicNarrow extends Narrow implements SendableNarrow { TopicNarrow sansWith() => TopicNarrow(streamId, topic); + /// Process [topic] to match how it would appear on a message object from + /// the server. + /// + /// Returns a new [TopicNarrow] with the [topic] processed. + /// + /// See [TopicName.processLikeServer]. + TopicNarrow processTopicLikeServer({ + required int zulipFeatureLevel, + required String? realmEmptyTopicDisplayName, + }) { + return TopicNarrow( + streamId, + topic.processLikeServer( + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName), + with_: with_); + } + @override bool containsMessage(MessageBase message) { final conversation = message.conversation; From 806153cb9f6eb7cdf6789672a8929d606cf21253 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Sat, 19 Jul 2025 04:26:37 +0530 Subject: [PATCH 299/423] msglist: Normalize topic name when opening message list Fixes: #1717 --- lib/widgets/message_list.dart | 17 ++++++++++++++++- test/widgets/message_list_test.dart | 11 +++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index eb0b972337..26021e108e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_color_models/flutter_color_models.dart'; import 'package:intl/intl.dart' hide TextDirection; @@ -772,8 +773,22 @@ class _MessageListState extends State with PerAccountStoreAwareStat } void _initModel(PerAccountStore store, Anchor anchor) { + // Normalize topic name if this is a TopicNarrow. See #1717. + var narrow = widget.narrow; + if (narrow is TopicNarrow) { + narrow = narrow.processTopicLikeServer( + zulipFeatureLevel: store.zulipFeatureLevel, + realmEmptyTopicDisplayName: store.zulipFeatureLevel > 334 + ? store.realmEmptyTopicDisplayName + : null); + if (narrow != widget.narrow) { + SchedulerBinding.instance.scheduleFrameCallback((_) { + widget.onNarrowChanged(narrow); + }); + } + } _model = MessageListView.init(store: store, - narrow: widget.narrow, anchor: anchor); + narrow: narrow, anchor: anchor); model.addListener(_modelChanged); model.fetchInitial(); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 8559508658..dc1c3d5d69 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -158,6 +158,17 @@ void main() { check(state.narrow).equals(ChannelNarrow(stream.streamId)); }); + testWidgets('MessageListPageState.narrow (general chat)', (tester) async { + final stream = eg.stream(); + final topic = eg.defaultRealmEmptyTopicDisplayName; + final topicNarrow = eg.topicNarrow(stream.streamId, topic); + await setupMessageListPage(tester, narrow: topicNarrow, + streams: [stream], + messages: [eg.streamMessage(stream: stream, topic: topic, content: "

a message

")]); + final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); + check(state.narrow).equals(eg.topicNarrow(stream.streamId, '')); + }); + testWidgets('composeBoxState finds compose box', (tester) async { final stream = eg.stream(); await setupMessageListPage(tester, narrow: ChannelNarrow(stream.streamId), From 09203d1fade8bf4011e88d2ad365ce65c8c9f9b1 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 17:12:24 -0700 Subject: [PATCH 300/423] msglist test [nfc]: Clarify the topic-normalization test --- test/widgets/message_list_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index dc1c3d5d69..54c714b34d 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -158,14 +158,17 @@ void main() { check(state.narrow).equals(ChannelNarrow(stream.streamId)); }); - testWidgets('MessageListPageState.narrow (general chat)', (tester) async { + testWidgets('narrow gets normalized from "general chat"', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1717 final stream = eg.stream(); + // Open the page on a topic with the literal name "general chat". final topic = eg.defaultRealmEmptyTopicDisplayName; final topicNarrow = eg.topicNarrow(stream.streamId, topic); await setupMessageListPage(tester, narrow: topicNarrow, streams: [stream], messages: [eg.streamMessage(stream: stream, topic: topic, content: "

a message

")]); final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); + // The page's narrow has been updated; the topic is "", not "general chat". check(state.narrow).equals(eg.topicNarrow(stream.streamId, '')); }); From 344b5f6239fc8aa3792bf1ed3056b1e09d89015b Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 1 Apr 2025 20:06:41 +0530 Subject: [PATCH 301/423] content: Support negative margins on KaTeX spans Negative margin spans on web render to the offset being applied to the specific span and all the adjacent spans, so mimic the same behaviour here. --- lib/model/content.dart | 22 ++++ lib/model/katex.dart | 87 +++++++++++--- lib/widgets/content.dart | 17 +++ lib/widgets/katex.dart | 199 +++++++++++++++++++++++++++++++++ test/model/content_test.dart | 117 +++++++++++++++++++ test/widgets/content_test.dart | 11 ++ 6 files changed, 436 insertions(+), 17 deletions(-) create mode 100644 lib/widgets/katex.dart diff --git a/lib/model/content.dart b/lib/model/content.dart index 9f906d1c4c..28486f634d 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -465,6 +465,28 @@ class KatexVlistRowNode extends ContentNode { } } +class KatexNegativeMarginNode extends KatexNode { + const KatexNegativeMarginNode({ + required this.leftOffsetEm, + required this.nodes, + super.debugHtmlNode, + }) : assert(leftOffsetEm < 0); + + final double leftOffsetEm; + final List nodes; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('leftOffsetEm', leftOffsetEm)); + } + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 8fe55dde77..f5cc85d95a 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:csslib/parser.dart' as css_parser; import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; @@ -167,16 +168,56 @@ class _KatexParser { } List _parseChildSpans(List nodes) { - return List.unmodifiable(nodes.map((node) { - if (node case dom.Element(localName: 'span')) { - return _parseSpan(node); - } else { + var resultSpans = QueueList(); + for (final node in nodes.reversed) { + if (node is! dom.Element || node.localName != 'span') { throw _KatexHtmlParseError( node is dom.Element ? 'unsupported html node: ${node.localName}' : 'unsupported html node'); } - })); + + var span = _parseSpan(node); + final negativeRightMarginEm = switch (span) { + KatexSpanNode(styles: KatexSpanStyles(:final marginRightEm?)) + when marginRightEm.isNegative => marginRightEm, + _ => null, + }; + final negativeLeftMarginEm = switch (span) { + KatexSpanNode(styles: KatexSpanStyles(:final marginLeftEm?)) + when marginLeftEm.isNegative => marginLeftEm, + _ => null, + }; + if (span is KatexSpanNode) { + if (negativeRightMarginEm != null || negativeLeftMarginEm != null) { + span = KatexSpanNode( + styles: span.styles.filter( + marginRightEm: negativeRightMarginEm == null, + marginLeftEm: negativeLeftMarginEm == null), + text: span.text, + nodes: span.nodes); + } + } + + if (negativeRightMarginEm != null) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: negativeRightMarginEm, + nodes: previousSpans)); + } + + resultSpans.addFirst(span); + + if (negativeLeftMarginEm != null) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: negativeLeftMarginEm, + nodes: previousSpans)); + } + } + return resultSpans; } static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); @@ -291,13 +332,31 @@ class _KatexParser { } final pstrutHeight = pstrutStyles.heightEm ?? 0; + KatexSpanNode innerSpanNode = KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)); + + final marginRightEm = styles.marginRightEm; + final marginLeftEm = styles.marginLeftEm; + if (marginRightEm != null && marginRightEm.isNegative) { + throw _KatexHtmlParseError(); + } + if (marginLeftEm != null && marginLeftEm.isNegative) { + innerSpanNode = KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm, + nodes: [innerSpanNode]), + ]); + } + rows.add(KatexVlistRowNode( verticalOffsetEm: topEm + pstrutHeight, debugHtmlNode: kDebugMode ? innerSpan : null, - node: KatexSpanNode( - styles: styles, - text: null, - nodes: _parseChildSpans(otherSpans)))); + node: innerSpanNode)); } else { throw _KatexHtmlParseError(); } @@ -630,17 +689,11 @@ class _KatexParser { case 'margin-right': marginRightEm = _getEm(expression); - if (marginRightEm != null) { - if (marginRightEm < 0) throw _KatexHtmlParseError(); - continue; - } + if (marginRightEm != null) continue; case 'margin-left': marginLeftEm = _getEm(expression); - if (marginLeftEm != null) { - if (marginLeftEm < 0) throw _KatexHtmlParseError(); - continue; - } + if (marginLeftEm != null) continue; } // TODO handle more CSS properties diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index ba05f5205e..b4ef707355 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -24,6 +24,7 @@ import 'dialog.dart'; import 'emoji.dart'; import 'icons.dart'; import 'inset_shadow.dart'; +import 'katex.dart'; import 'lightbox.dart'; import 'message_list.dart'; import 'poll.dart'; @@ -898,6 +899,7 @@ class _KatexNodeList extends StatelessWidget { KatexSpanNode() => _KatexSpan(e), KatexStrutNode() => _KatexStrut(e), KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), })); })))); } @@ -1046,6 +1048,21 @@ class _KatexVlist extends StatelessWidget { } } +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return NegativeLeftOffset( + leftOffset: node.leftOffsetEm * em, + child: _KatexNodeList(nodes: node.nodes)); + } +} + class WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart new file mode 100644 index 0000000000..9b89270c8b --- /dev/null +++ b/lib/widgets/katex.dart @@ -0,0 +1,199 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +class NegativeLeftOffset extends SingleChildRenderObjectWidget { + NegativeLeftOffset({super.key, required this.leftOffset, super.child}) + : assert(leftOffset.isNegative), + _padding = EdgeInsets.only(left: leftOffset); + + final double leftOffset; + final EdgeInsetsGeometry _padding; + + @override + RenderNegativePadding createRenderObject(BuildContext context) { + return RenderNegativePadding( + padding: _padding, + textDirection: Directionality.maybeOf(context)); + } + + @override + void updateRenderObject( + BuildContext context, + RenderNegativePadding renderObject, + ) { + renderObject + ..padding = _padding + ..textDirection = Directionality.maybeOf(context); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', _padding)); + } +} + +// Like [RenderPadding] but only supports negative values. +// TODO(upstream): give Padding an option to accept negative padding (at cost of hit-testing not working) +class RenderNegativePadding extends RenderShiftedBox { + RenderNegativePadding({ + required EdgeInsetsGeometry padding, + TextDirection? textDirection, + RenderBox? child, + }) : assert(!padding.isNonNegative), + _textDirection = textDirection, + _padding = padding, + super(child); + + EdgeInsets? _resolvedPaddingCache; + EdgeInsets get _resolvedPadding { + final EdgeInsets returnValue = _resolvedPaddingCache ??= padding.resolve(textDirection); + return returnValue; + } + + void _markNeedResolution() { + _resolvedPaddingCache = null; + markNeedsLayout(); + } + + /// The amount to pad the child in each dimension. + /// + /// If this is set to an [EdgeInsetsDirectional] object, then [textDirection] + /// must not be null. + EdgeInsetsGeometry get padding => _padding; + EdgeInsetsGeometry _padding; + set padding(EdgeInsetsGeometry value) { + assert(!value.isNonNegative); + if (_padding == value) { + return; + } + _padding = value; + _markNeedResolution(); + } + + /// The text direction with which to resolve [padding]. + /// + /// This may be changed to null, but only after the [padding] has been changed + /// to a value that does not depend on the direction. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + _markNeedResolution(); + } + + @override + double computeMinIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMinIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + return constraints.constrain(Size(padding.horizontal, padding.vertical)); + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + final Size childSize = child!.getDryLayout(innerConstraints); + return constraints.constrain( + Size(padding.horizontal + childSize.width, padding.vertical + childSize.height), + ); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final EdgeInsets padding = _resolvedPadding; + final BoxConstraints innerConstraints = constraints.deflate(padding); + final BaselineOffset result = + BaselineOffset(child.getDryBaseline(innerConstraints, baseline)) + padding.top; + return result.offset; + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + size = constraints.constrain(Size(padding.horizontal, padding.vertical)); + return; + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + child!.layout(innerConstraints, parentUsesSize: true); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset(padding.left, padding.top); + size = constraints.constrain( + Size(padding.horizontal + child!.size.width, padding.vertical + child!.size.height), + ); + } + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + super.debugPaintSize(context, offset); + assert(() { + final Rect outerRect = offset & size; + debugPaintPadding( + context.canvas, + outerRect, + child != null ? _resolvedPaddingCache!.deflateRect(outerRect) : null, + ); + return true; + }()); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', padding)); + properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); + } +} diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 93e94b5f85..f1f9038795 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1165,6 +1165,121 @@ class ContentExample { ]), ]); + static const mathBlockKatexNegativeMargin = ContentExample( + 'math block, KaTeX negative margin', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 + '```math\n1 \\! 2\n```', + '

' + '' + '1 ⁣21 \\! 2' + '

', [ + MathBlockNode(texSource: '1 \\! 2', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), text: '1', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: '2', nodes: null), + ]), + ]), + ]), + ]); + + static const mathBlockKatexLogo = ContentExample( + 'math block, KaTeX logo', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 + '```math\n\\KaTeX\n```', + '

' + '' + 'KaTeX' + '\\KaTeX' + '

', [ + MathBlockNode(texSource: '\\KaTeX', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'K', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 + text: 'A', nodes: null), + ]), + ])), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'T', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'E', nodes: null), + ]), + ])), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'X', nodes: null), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2259,6 +2374,8 @@ void main() async { testParseExample(ContentExample.mathBlockKatexSubscript); testParseExample(ContentExample.mathBlockKatexSubSuperScript); testParseExample(ContentExample.mathBlockKatexRaisebox); + testParseExample(ContentExample.mathBlockKatexNegativeMargin); + testParseExample(ContentExample.mathBlockKatexLogo); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 7cc249d79b..bcc210e13c 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -619,6 +619,17 @@ void main() { ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), ]), + (ContentExample.mathBlockKatexNegativeMargin, skip: false, [ + ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), + ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), + ]), + (ContentExample.mathBlockKatexLogo, skip: false, [ + ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), + ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), + ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), + ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), + ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), + ]), ]; for (final testCase in testCases) { From 01bff8ea5347a40085bb00cd5b43cfd32956a9ef Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 17 Jul 2025 05:47:33 +0530 Subject: [PATCH 302/423] content test: Add test for negative margins on a vlist row in KaTeX content --- test/model/content_test.dart | 65 ++++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 9 ++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/test/model/content_test.dart b/test/model/content_test.dart index f1f9038795..5fb3ddc5d2 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1280,6 +1280,70 @@ class ContentExample { ]), ]); + static const mathBlockKatexNegativeMarginsOnVlistRow = ContentExample( + 'math block, KaTeX negative margins on a vlist row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 + '```math\nX_n\n```', + '

' + '' + 'XnX_n' + '

', [ + MathBlockNode(texSource: 'X_n', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + marginRightEm: 0.07847, + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'X', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + marginRightEm: 0.05, + // TODO parser should not emit this `marginLeftEm` here because + // it already generates `KatexNegativeMarginNode` for handling it. + marginLeftEm: -0.0785), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n', nodes: null), + ]), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2376,6 +2440,7 @@ void main() async { testParseExample(ContentExample.mathBlockKatexRaisebox); testParseExample(ContentExample.mathBlockKatexNegativeMargin); testParseExample(ContentExample.mathBlockKatexLogo); + testParseExample(ContentExample.mathBlockKatexNegativeMarginsOnVlistRow); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index bcc210e13c..f24c018af7 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -630,6 +630,13 @@ void main() { ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), ]), + // TODO re-enable this test when parser fixes a bug where + // it emits negative margin in styles, allowing widget + // code to hit an assert. + (ContentExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: true, [ + ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), + ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), + ]), ]; for (final testCase in testCases) { @@ -659,7 +666,7 @@ void main() { check(size) .within(distance: 0.05, from: expectedSize); } - }); + }, skip: testCase.skip); } }); }); From 64956b8f0df83e8ccd7aff28c0ce03beb09bbc46 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 17 Jul 2025 05:56:01 +0530 Subject: [PATCH 303/423] content: Filter negative margin styles if present on a vlist row This fixes a bug where if negative margin on a vlist row is present the widget side code would hit an assert. Displaying the error (red screen) in debug mode, but in release mode the negative padding would be applied twice, once by `_KatexNegativeMargin` and another by `Padding` widget. --- lib/model/katex.dart | 21 ++++++++++++--------- test/model/content_test.dart | 6 +----- test/widgets/content_test.dart | 5 +---- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index f5cc85d95a..42b3277cee 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -332,10 +332,7 @@ class _KatexParser { } final pstrutHeight = pstrutStyles.heightEm ?? 0; - KatexSpanNode innerSpanNode = KatexSpanNode( - styles: styles, - text: null, - nodes: _parseChildSpans(otherSpans)); + final KatexSpanNode innerSpanNode; final marginRightEm = styles.marginRightEm; final marginLeftEm = styles.marginLeftEm; @@ -346,11 +343,17 @@ class _KatexParser { innerSpanNode = KatexSpanNode( styles: KatexSpanStyles(), text: null, - nodes: [ - KatexNegativeMarginNode( - leftOffsetEm: marginLeftEm, - nodes: [innerSpanNode]), - ]); + nodes: [KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm, + nodes: [KatexSpanNode( + styles: styles.filter(marginLeftEm: false), + text: null, + nodes: _parseChildSpans(otherSpans))])]); + } else { + innerSpanNode = KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)); } rows.add(KatexVlistRowNode( diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 5fb3ddc5d2..e4dd622b19 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1321,11 +1321,7 @@ class ContentExample { node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ KatexSpanNode( - styles: KatexSpanStyles( - marginRightEm: 0.05, - // TODO parser should not emit this `marginLeftEm` here because - // it already generates `KatexNegativeMarginNode` for handling it. - marginLeftEm: -0.0785), + styles: KatexSpanStyles(marginRightEm: 0.05), text: null, nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index f24c018af7..964d7e1208 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -630,10 +630,7 @@ void main() { ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), ]), - // TODO re-enable this test when parser fixes a bug where - // it emits negative margin in styles, allowing widget - // code to hit an assert. - (ContentExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: true, [ + (ContentExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), ]), From 66d3ed9cfbc4ab94ffe2e431b45907abfb7de632 Mon Sep 17 00:00:00 2001 From: Apoorva Pendse Date: Sat, 19 Jul 2025 09:39:56 +0530 Subject: [PATCH 304/423] doc: Add missing project board link in README. This adds back the definition for the project board link that was dropped as a part of d3d77215508cc0a281de8d2b84292e4bc3394339. Discussion: https://chat.zulip.org/#narrow/channel/19-documentation/topic/Missing.20project.20board.20link.20in.20flutter.20readme Signed-off-by: Apoorva Pendse --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1bc12b65b6..979d096e4c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ and describing your progress. [your first codebase contribution]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#your-first-codebase-contribution [what makes a great Zulip contributor]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#what-makes-a-great-zulip-contributor +[project board]: https://github.com/orgs/zulip/projects/5/views/4 [picking an issue to work on]: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#picking-an-issue-to-work-on From fe186b29a624fb4c348416504cf7101f57d9350e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 21 Jul 2025 11:37:46 -0700 Subject: [PATCH 305/423] doc: Update milestone reference in README We completed the "Launch" milestone a little while ago. :-) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 979d096e4c..d0517f9442 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ browsing through recent commits and the codebase, and the Zulip guide to Git. To find possible issues to work on, see our [project board][]. -Look for issues up through the "Launch" milestone, +Look for issues in the earliest milestone, and that aren't already assigned. Follow the Zulip guide to [picking an issue to work on][], From b073c6b26f785ff72530e23e768e58d33d30211b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 11 Jun 2025 22:02:06 -0700 Subject: [PATCH 306/423] content: Render KaTeX by default, the way we have in recent releases This is cherry-picked from commits we included in each of the last several releases, starting with 8f3723768 in v0.0.31. --- lib/model/settings.dart | 2 +- test/model/content_test.dart | 4 ++-- test/widgets/content_test.dart | 29 +++++++++++++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/model/settings.dart b/lib/model/settings.dart index e8a309ac29..4e71224444 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -178,7 +178,7 @@ enum BoolGlobalSetting { upgradeWelcomeDialogShown(GlobalSettingType.internal, false), /// An experimental flag to toggle rendering KaTeX content in messages. - renderKatex(GlobalSettingType.experimentalFeatureFlag, false), + renderKatex(GlobalSettingType.experimentalFeatureFlag, true), /// An experimental flag to enable rendering KaTeX even when some /// errors are encountered. diff --git a/test/model/content_test.dart b/test/model/content_test.dart index e4dd622b19..88bd11c66c 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -512,7 +512,7 @@ class ContentExample { static final mathInline = ContentExample.inline( 'inline math', r"$$ \lambda $$", - expectedText: r'\lambda', + expectedText: r'λ', '

' 'λ' ' \\lambda ' @@ -532,7 +532,7 @@ class ContentExample { static const mathBlock = ContentExample( 'math block', "```math\n\\lambda\n```", - expectedText: r'\lambda', + expectedText: r'λ', '

' 'λ' '\\lambda' diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 964d7e1208..46ed10079e 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -558,7 +558,11 @@ void main() { group('MathBlock', () { testContentSmoke(ContentExample.mathBlock); - testWidgets('displays KaTeX source; experimental flag default', (tester) async { + testWidgets('displays KaTeX source; experimental flag disabled', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, false); + await prepareContent(tester, plainContent(ContentExample.mathBlock.html)); tester.widget(find.text(r'\lambda', findRichText: true)); }); @@ -1102,6 +1106,23 @@ void main() { }); testWidgets('maintains font-size ratio with surrounding text, when showing TeX source', (tester) async { + const html = '' + 'λ' + ' \\lambda ' + ''; + await checkFontSizeRatio(tester, + targetHtml: html, + targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'λ')); + }, skip: true // TODO(#46): adapt this test + // (it needs a more complex targetFontSizeFinder; + // see other uses in this file for examples.) + ); + + testWidgets('maintains font-size ratio with surrounding text, when showing TeX source', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, false); + const html = '' 'λ' ' \\lambda ' @@ -1111,7 +1132,11 @@ void main() { targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'\lambda')); }); - testWidgets('displays KaTeX source; experimental flag default', (tester) async { + testWidgets('displays KaTeX source; experimental flag disabled', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, false); + await prepareContent(tester, plainContent(ContentExample.mathInline.html)); tester.widget(find.text(r'\lambda', findRichText: true)); }); From b773c007b7270c1abd3f747027a5cc09dbf9b625 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 20:35:12 -0700 Subject: [PATCH 307/423] katex [nfc]: Show line numbers only on unknown hard-fails, not others This makes the output of the survey script more stable as our KaTeX parser gets refactored and otherwise edited. --- lib/model/katex.dart | 6 +++--- tools/content/unimplemented_katex_test.dart | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 42b3277cee..d1655aad32 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -18,11 +18,11 @@ import 'settings.dart'; /// a specific node. class KatexParserHardFailReason { const KatexParserHardFailReason({ - required this.error, + required this.message, required this.stackTrace, }); - final String error; + final String? message; final StackTrace stackTrace; } @@ -132,7 +132,7 @@ MathParserResult? parseMath(dom.Element element, { required bool block }) { } on _KatexHtmlParseError catch (e, st) { assert(debugLog('$e\n$st')); hardFailReason = KatexParserHardFailReason( - error: e.message ?? 'unknown', + message: e.message, stackTrace: st); } diff --git a/tools/content/unimplemented_katex_test.dart b/tools/content/unimplemented_katex_test.dart index 80b0f482a7..aca9d82af4 100644 --- a/tools/content/unimplemented_katex_test.dart +++ b/tools/content/unimplemented_katex_test.dart @@ -59,8 +59,9 @@ void main() async { int failureCount = 0; if (hardFailReason != null) { - final firstLine = hardFailReason.stackTrace.toString().split('\n').first; - final reason = 'hard fail: ${hardFailReason.error} "$firstLine"'; + final message = hardFailReason.message + ?? 'unknown reason at ${_inmostFrame(hardFailReason.stackTrace)}'; + final reason = 'hard fail: $message'; (failedMessageIdsByReason[reason] ??= {}).add(messageId); (failedMathNodesByReason[reason] ??= {}).add(value); failureCount++; @@ -156,6 +157,16 @@ void main() async { }); } +/// The innermost frame of the given stack trace, +/// e.g. the line where an exception was thrown. +/// +/// Inevitably this is a bit heuristic, given the lack of any API guarantees +/// on the structure of [StackTrace]. +String _inmostFrame(StackTrace stackTrace) { + final firstLine = stackTrace.toString().split('\n').first; + return firstLine.replaceFirst(RegExp(r'^#\d+\s+'), ''); +} + const String _corpusDirPath = String.fromEnvironment('corpusDir'); const bool _verbose = int.fromEnvironment('verbose', defaultValue: 0) != 0; From 20294ed7c11a18e1f51afe60416fbf13d582a5cf Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 20:40:09 -0700 Subject: [PATCH 308/423] katex [nfc]: Add messages for remaining hard-fail cases seen in corpus --- lib/model/katex.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index d1655aad32..c9b7cdfd58 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -386,7 +386,9 @@ class _KatexParser { // Currently, we expect `top` to only be inside a vlist, and // we handle that case separately above. - if (inlineStyles.topEm != null) throw _KatexHtmlParseError(); + if (inlineStyles.topEm != null) { + throw _KatexHtmlParseError('unsupported inline CSS property: top'); + } } // Aggregate the CSS styles that apply, in the same order as the CSS @@ -586,7 +588,7 @@ class _KatexParser { 'size4' => 'KaTeX_Size4', 'mult' => // TODO handle nested spans with `.delim-size{1,4}` class. - throw _KatexHtmlParseError(), + throw _KatexHtmlParseError('unimplemented CSS class pair: .delimsizing.mult'), _ => throw _KatexHtmlParseError(), }; @@ -705,7 +707,7 @@ class _KatexParser { unsupportedInlineCssProperties.add(property); _hasError = true; } else { - throw _KatexHtmlParseError(); + throw _KatexHtmlParseError('unexpected shape of inline CSS'); } } From 545ecfa75547b0505239e659031978aa1e34b08e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 20:59:40 -0700 Subject: [PATCH 309/423] tools/content [nfc]: Tie-break on reason text when number of failures equal This helps make the output more stable from run to run, so that it's easier to spot changes (or confirm the absence of changes) when editing the code. --- tools/content/unimplemented_katex_test.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tools/content/unimplemented_katex_test.dart b/tools/content/unimplemented_katex_test.dart index aca9d82af4..bb89052f24 100644 --- a/tools/content/unimplemented_katex_test.dart +++ b/tools/content/unimplemented_katex_test.dart @@ -103,9 +103,13 @@ void main() async { 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), - )) { + for (final MapEntry(key: reason, value: messageIds) + in failedMessageIdsByReason.entries.sorted((a, b) { + // Sort by number of failed messages descending, then by reason. + final r = - a.value.length.compareTo(b.value.length); + if (r != 0) return r; + return a.key.compareTo(b.key); + })) { final failedMathNodes = failedMathNodesByReason[reason]!.toList(); failedMathNodes.shuffle(); final oldestId = messageIds.reduce(min); From 1fd2f027217238dd6c98a1239b096731b4e4bd7a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 18:50:09 -0700 Subject: [PATCH 310/423] katex [nfc]: Cut stray import of widgets library This was here only for a reference in a doc. In general we try to avoid imports of widgets from model code; it's an inversion of layers. --- lib/model/katex.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index c9b7cdfd58..0305543459 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -2,7 +2,6 @@ import 'package:collection/collection.dart'; 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'; From a914be549655697c5f946801bf34805b8a3752f2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 18:53:49 -0700 Subject: [PATCH 311/423] binding [nfc]: Cut stray import of a widgets library This was here only for references in docs. Better to avoid the layer-inverting import. --- lib/model/binding.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 4d1a0adaac..2ad21d939b 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -13,7 +13,6 @@ 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'; export 'package:file_picker/file_picker.dart' show FilePickerResult, FileType, PlatformFile; From 5ab997b461035cb203dc027b95cfc3f8fc7cdbb3 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 00:02:51 -0700 Subject: [PATCH 312/423] katex [nfc]: Fix a variable name to specify its units, namely em --- lib/model/katex.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 0305543459..ceb7082703 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -329,7 +329,7 @@ class _KatexParser { != const KatexSpanStyles()) { throw _KatexHtmlParseError(); } - final pstrutHeight = pstrutStyles.heightEm ?? 0; + final pstrutHeightEm = pstrutStyles.heightEm ?? 0; final KatexSpanNode innerSpanNode; @@ -356,7 +356,7 @@ class _KatexParser { } rows.add(KatexVlistRowNode( - verticalOffsetEm: topEm + pstrutHeight, + verticalOffsetEm: topEm + pstrutHeightEm, debugHtmlNode: kDebugMode ? innerSpan : null, node: innerSpanNode)); } else { From e0d80448978cab88637c533070a36ba9f113b4fa Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 13:32:24 -0700 Subject: [PATCH 313/423] katex: Require height on pstrut spans If this were missing, it's not clear to me that zero would be an appropriate default. In any case, in an empirical corpus, it's always present. So just require that. --- lib/model/katex.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index ceb7082703..c6e33f17e5 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -329,7 +329,8 @@ class _KatexParser { != const KatexSpanStyles()) { throw _KatexHtmlParseError(); } - final pstrutHeightEm = pstrutStyles.heightEm ?? 0; + final pstrutHeightEm = pstrutStyles.heightEm; + if (pstrutHeightEm == null) throw _KatexHtmlParseError(); final KatexSpanNode innerSpanNode; From 8909476b8a8afad3e7826923651ad280cedd6fef Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 23:32:49 -0700 Subject: [PATCH 314/423] katex [nfc]: Separate _parseInlineStyles from constructing a KatexSpanStyles Also document the existing _parseSpanInlineStyles method, and describe our plan for eliminating most of its call sites. --- lib/model/katex.dart | 119 ++++++++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 52 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index c6e33f17e5..a49d081308 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -660,64 +660,79 @@ class _KatexParser { debugHtmlNode: debugHtmlNode); } + /// Parse the inline CSS styles from the given element, + /// and look for the styles we know how to interpret for a generic KaTeX span. + /// + /// TODO: This has a number of call sites that aren't acting on a generic + /// KaTeX span, but instead on spans in particular roles where we have + /// much more specific expectations on the inline styles. + /// For those, switch to [_parseInlineStyles] and inspect the styles directly. KatexSpanStyles? _parseSpanInlineStyles(dom.Element element) { + final declarations = _parseInlineStyles(element); + if (declarations == null) return null; + + double? heightEm; + double? verticalAlignEm; + double? topEm; + double? marginRightEm; + double? marginLeftEm; + + for (final declaration in 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; + + case 'vertical-align': + verticalAlignEm = _getEm(expression); + if (verticalAlignEm != null) continue; + + case 'top': + topEm = _getEm(expression); + if (topEm != null) continue; + + case 'margin-right': + marginRightEm = _getEm(expression); + if (marginRightEm != null) continue; + + case 'margin-left': + marginLeftEm = _getEm(expression); + if (marginLeftEm != null) continue; + } + + // TODO handle more CSS properties + assert(debugLog('KaTeX: Unsupported CSS expression:' + ' ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + } else { + throw _KatexHtmlParseError('unexpected shape of inline CSS'); + } + } + + return KatexSpanStyles( + heightEm: heightEm, + topEm: topEm, + verticalAlignEm: verticalAlignEm, + marginRightEm: marginRightEm, + marginLeftEm: marginLeftEm, + ); + } + + /// Parse the inline CSS styles from the given element. + static Iterable? _parseInlineStyles(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; - double? verticalAlignEm; - double? topEm; - double? marginRightEm; - double? marginLeftEm; - - 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; - - case 'vertical-align': - verticalAlignEm = _getEm(expression); - if (verticalAlignEm != null) continue; - - case 'top': - topEm = _getEm(expression); - if (topEm != null) continue; - - case 'margin-right': - marginRightEm = _getEm(expression); - if (marginRightEm != null) continue; - - case 'margin-left': - marginLeftEm = _getEm(expression); - if (marginLeftEm != null) continue; - } - - // TODO handle more CSS properties - assert(debugLog('KaTeX: Unsupported CSS expression:' - ' ${expression.toDebugString()}')); - unsupportedInlineCssProperties.add(property); - _hasError = true; - } else { - throw _KatexHtmlParseError('unexpected shape of inline CSS'); - } - } - - return KatexSpanStyles( - heightEm: heightEm, - topEm: topEm, - verticalAlignEm: verticalAlignEm, - marginRightEm: marginRightEm, - marginLeftEm: marginLeftEm, - ); + if (stylesheet.topLevels case [css_visitor.RuleSet() && final ruleSet]) { + return ruleSet.declarationGroup.declarations; } else { throw _KatexHtmlParseError(); } From f935050dee7c00c417c1a27df1edec7707ca6b0b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 23:44:23 -0700 Subject: [PATCH 315/423] katex [nfc]: Push more parsing into _parseInlineStyles; return Map This has a small effect on the survey script's list of failure reasons: in the rare case that the "unexpected shape of inline CSS" error appears, it now fires before any of the CSS properties in the same inline style are processed. That can mean fewer entries added to unsupportedInlineCssProperties. This difference is only possible, though, on a KaTeX expression that is going to reach the same hard failure either way. So it has no effect on behavior seen by a user. --- lib/model/katex.dart | 83 ++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index a49d081308..bea2ed40c2 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -677,42 +677,37 @@ class _KatexParser { double? marginRightEm; double? marginLeftEm; - for (final declaration in 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; - - case 'vertical-align': - verticalAlignEm = _getEm(expression); - if (verticalAlignEm != null) continue; - - case 'top': - topEm = _getEm(expression); - if (topEm != null) continue; - - case 'margin-right': - marginRightEm = _getEm(expression); - if (marginRightEm != null) continue; - - case 'margin-left': - marginLeftEm = _getEm(expression); - if (marginLeftEm != null) continue; - } - - // TODO handle more CSS properties - assert(debugLog('KaTeX: Unsupported CSS expression:' - ' ${expression.toDebugString()}')); - unsupportedInlineCssProperties.add(property); - _hasError = true; - } else { - throw _KatexHtmlParseError('unexpected shape of inline CSS'); + for (final declaration in declarations.entries) { + final property = declaration.key; + final expression = declaration.value; + + switch (property) { + case 'height': + heightEm = _getEm(expression); + if (heightEm != null) continue; + + case 'vertical-align': + verticalAlignEm = _getEm(expression); + if (verticalAlignEm != null) continue; + + case 'top': + topEm = _getEm(expression); + if (topEm != null) continue; + + case 'margin-right': + marginRightEm = _getEm(expression); + if (marginRightEm != null) continue; + + case 'margin-left': + marginLeftEm = _getEm(expression); + if (marginLeftEm != null) continue; } + + // TODO handle more CSS properties + assert(debugLog('KaTeX: Unsupported CSS expression:' + ' ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; } return KatexSpanStyles( @@ -725,14 +720,28 @@ class _KatexParser { } /// Parse the inline CSS styles from the given element. - static Iterable? _parseInlineStyles(dom.Element element) { + static Map? _parseInlineStyles(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 ruleSet]) { - return ruleSet.declarationGroup.declarations; + final result = {}; + for (final declaration in ruleSet.declarationGroup.declarations) { + if (declaration case css_visitor.Declaration( + :final property, + expression: css_visitor.Expressions( + expressions: [css_visitor.Expression() && final expression]), + )) { + result.update(property, ifAbsent: () => expression, + (_) => throw _KatexHtmlParseError( + 'duplicate inline CSS property: $property')); + } else { + throw _KatexHtmlParseError('unexpected shape of inline CSS'); + } + } + return result; } else { throw _KatexHtmlParseError(); } From eabcca18d74289f2ef510d517c1f7ac94b61704a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 14:11:33 -0700 Subject: [PATCH 316/423] katex [nfc]: Factor out _takeStyleEm from _parseSpanInlineStyles Also make the error message for this case a bit more specific. --- lib/model/katex.dart | 67 +++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index bea2ed40c2..41c0749d48 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -671,55 +671,31 @@ class _KatexParser { final declarations = _parseInlineStyles(element); if (declarations == null) return null; - double? heightEm; - double? verticalAlignEm; - double? topEm; - double? marginRightEm; - double? marginLeftEm; + final result = KatexSpanStyles( + heightEm: _takeStyleEm(declarations, 'height'), + topEm: _takeStyleEm(declarations, 'top'), + verticalAlignEm: _takeStyleEm(declarations, 'vertical-align'), + marginRightEm: _takeStyleEm(declarations, 'margin-right'), + marginLeftEm: _takeStyleEm(declarations, 'margin-left'), + // TODO handle more CSS properties + ); for (final declaration in declarations.entries) { final property = declaration.key; final expression = declaration.value; - switch (property) { - case 'height': - heightEm = _getEm(expression); - if (heightEm != null) continue; - - case 'vertical-align': - verticalAlignEm = _getEm(expression); - if (verticalAlignEm != null) continue; - - case 'top': - topEm = _getEm(expression); - if (topEm != null) continue; - - case 'margin-right': - marginRightEm = _getEm(expression); - if (marginRightEm != null) continue; - - case 'margin-left': - marginLeftEm = _getEm(expression); - if (marginLeftEm != null) continue; - } - - // TODO handle more CSS properties assert(debugLog('KaTeX: Unsupported CSS expression:' ' ${expression.toDebugString()}')); unsupportedInlineCssProperties.add(property); _hasError = true; } - return KatexSpanStyles( - heightEm: heightEm, - topEm: topEm, - verticalAlignEm: verticalAlignEm, - marginRightEm: marginRightEm, - marginLeftEm: marginLeftEm, - ); + return result; } /// Parse the inline CSS styles from the given element. + /// + /// To interpret the resulting map, consider [_takeStyleEm]. static Map? _parseInlineStyles(dom.Element element) { if (element.attributes case {'style': final styleStr}) { // `package:csslib` doesn't seem to have a way to parse inline styles: @@ -749,12 +725,27 @@ class _KatexParser { 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) { + /// Remove the given property from the given style map, + /// and parse as a length in ems. + /// + /// If the property is present but is not a length in ems, + /// record an error and return null. + /// + /// If the property is absent, return null with no error. + /// + /// If the map is null, treat it as empty. + /// + /// To produce the map this method expects, see [_parseInlineStyles]. + double? _takeStyleEm(Map? styles, String property) { + final expression = styles?.remove(property); + if (expression == null) return null; if (expression is css_visitor.EmTerm && expression.value is num) { return (expression.value as num).toDouble(); } + assert(debugLog('KaTeX: Unsupported value for CSS property $property,' + ' expected a length in em: ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; return null; } } From 95ae2937fdb3f3a07e5d9bf6ab88cd5706f30b28 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 14:16:12 -0700 Subject: [PATCH 317/423] katex: Fix a misleading log line: unexpected CSS property, not value Until the previous commit, this bit of code was handling both the case where the value was unexpected (for which this message was accurate) and the case where the property itself was unexpected (for which it wasn't). Now the first case is handled elsewhere, so fix the remaining case. --- 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 41c0749d48..4f9aca6c06 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -680,12 +680,8 @@ class _KatexParser { // TODO handle more CSS properties ); - for (final declaration in declarations.entries) { - final property = declaration.key; - final expression = declaration.value; - - assert(debugLog('KaTeX: Unsupported CSS expression:' - ' ${expression.toDebugString()}')); + for (final property in declarations.keys) { + assert(debugLog('KaTeX: Unexpected inline CSS property: $property')); unsupportedInlineCssProperties.add(property); _hasError = true; } From b9c7bc4e87842fae565106ed67f0cf2e9dea6323 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 23:53:45 -0700 Subject: [PATCH 318/423] katex [nfc]: Skip building whole KatexSpanStyles for struts' two properties We know at this spot that there are just two specific CSS properties we expect to see, and we'll end up handling them directly rather than through a KatexSpanStyles object. So parse them directly, rather than build a whole KatexSpanStyles object (and then another one with `.filter()`). --- lib/model/katex.dart | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 4f9aca6c06..a1bb293c37 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -230,18 +230,12 @@ class _KatexParser { if (element.className == 'strut') { if (element.nodes.isNotEmpty) throw _KatexHtmlParseError(); - final styles = _parseSpanInlineStyles(element); + final styles = _parseInlineStyles(element); if (styles == null) throw _KatexHtmlParseError(); - - final heightEm = styles.heightEm; + final heightEm = _takeStyleEm(styles, 'height'); if (heightEm == null) throw _KatexHtmlParseError(); - final verticalAlignEm = styles.verticalAlignEm; - - // Ensure only `height` and `vertical-align` inline styles are present. - if (styles.filter(heightEm: false, verticalAlignEm: false) - != const KatexSpanStyles()) { - throw _KatexHtmlParseError(); - } + final verticalAlignEm = _takeStyleEm(styles, 'vertical-align'); + if (styles.isNotEmpty) throw _KatexHtmlParseError(); return KatexStrutNode( heightEm: heightEm, From 8ac8a439b6d0bb35dd3767e12c91e52d6d437827 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 00:11:27 -0700 Subject: [PATCH 319/423] katex [nfc]: Cut vertical-align from generic style properties All the remaining call sites of _parseSpanInlineStyles would throw anyway if this property were actually found. We only expect it in a specific context, namely a strut. --- lib/model/katex.dart | 17 ++++------------- lib/widgets/content.dart | 4 ---- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index a1bb293c37..560ded094b 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -312,7 +312,6 @@ class _KatexParser { var styles = _parseSpanInlineStyles(innerSpan); if (styles == null) throw _KatexHtmlParseError(); - if (styles.verticalAlignEm != null) throw _KatexHtmlParseError(); final topEm = styles.topEm ?? 0; styles = styles.filter(topEm: false); @@ -374,10 +373,6 @@ class _KatexParser { final inlineStyles = _parseSpanInlineStyles(element); if (inlineStyles != null) { - // We expect `vertical-align` inline style to be only present on a - // `strut` span, for which we emit `KatexStrutNode` separately. - if (inlineStyles.verticalAlignEm != null) throw _KatexHtmlParseError(); - // Currently, we expect `top` to only be inside a vlist, and // we handle that case separately above. if (inlineStyles.topEm != null) { @@ -668,7 +663,6 @@ class _KatexParser { final result = KatexSpanStyles( heightEm: _takeStyleEm(declarations, 'height'), topEm: _takeStyleEm(declarations, 'top'), - verticalAlignEm: _takeStyleEm(declarations, 'vertical-align'), marginRightEm: _takeStyleEm(declarations, 'margin-right'), marginLeftEm: _takeStyleEm(declarations, 'margin-left'), // TODO handle more CSS properties @@ -758,7 +752,10 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { final double? heightEm; - final double? verticalAlignEm; + + // We expect `vertical-align` inline style to be only present on a + // `strut` span, for which we emit `KatexStrutNode` separately. + // final double? verticalAlignEm; final double? topEm; @@ -773,7 +770,6 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, - this.verticalAlignEm, this.topEm, this.marginRightEm, this.marginLeftEm, @@ -788,7 +784,6 @@ class KatexSpanStyles { int get hashCode => Object.hash( 'KatexSpanStyles', heightEm, - verticalAlignEm, topEm, marginRightEm, marginLeftEm, @@ -803,7 +798,6 @@ class KatexSpanStyles { bool operator ==(Object other) { return other is KatexSpanStyles && other.heightEm == heightEm && - other.verticalAlignEm == verticalAlignEm && other.topEm == topEm && other.marginRightEm == marginRightEm && other.marginLeftEm == marginLeftEm && @@ -818,7 +812,6 @@ class KatexSpanStyles { String toString() { final args = []; if (heightEm != null) args.add('heightEm: $heightEm'); - if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); if (topEm != null) args.add('topEm: $topEm'); if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm'); @@ -840,7 +833,6 @@ class KatexSpanStyles { KatexSpanStyles merge(KatexSpanStyles other) { return KatexSpanStyles( heightEm: other.heightEm ?? heightEm, - verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, topEm: other.topEm ?? topEm, marginRightEm: other.marginRightEm ?? marginRightEm, marginLeftEm: other.marginLeftEm ?? marginLeftEm, @@ -866,7 +858,6 @@ class KatexSpanStyles { }) { return KatexSpanStyles( heightEm: heightEm ? this.heightEm : null, - verticalAlignEm: verticalAlignEm ? this.verticalAlignEm : null, topEm: topEm ? this.topEm : null, marginRightEm: marginRightEm ? this.marginRightEm : null, marginLeftEm: marginLeftEm ? this.marginLeftEm : null, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index b4ef707355..2263b74f8b 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -922,10 +922,6 @@ class _KatexSpan extends StatelessWidget { } final styles = node.styles; - // We expect vertical-align to be only present with the - // `strut` span, for which parser explicitly emits `KatexStrutNode`. - // So, this should always be null for non `strut` spans. - assert(styles.verticalAlignEm == null); // Currently, we expect `top` to be only present with the // vlist inner row span, and parser handles that explicitly. From d44e7303c2d8016fe9dc2e7c0915b5f5b2612cc2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 00:00:55 -0700 Subject: [PATCH 320/423] katex [nfc]: Check "only has height" directly, without making KatexSpanStyles This lets us skip allocating these objects (two in each case -- the second one comes from `.filter()`). We also get to skip parsing the value of `height`, since we don't intend to use it. --- lib/model/katex.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 560ded094b..024d7c9b4f 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -265,9 +265,8 @@ class _KatexParser { // a "height" inline style which we ignore, because it doesn't seem // to have any effect in rendering on the web. // But also make sure there aren't any other inline styles present. - final vlistStyles = _parseSpanInlineStyles(vlist); - if (vlistStyles != null - && vlistStyles.filter(heightEm: false) != const KatexSpanStyles()) { + final vlistStyles = _parseInlineStyles(vlist); + if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { throw _KatexHtmlParseError(); } } else { @@ -288,9 +287,8 @@ class _KatexParser { // because it doesn't seem to have any effect in rendering on // the web. // But also make sure there aren't any other inline styles present. - final vlistStyles = _parseSpanInlineStyles(vlist); - if (vlistStyles != null - && vlistStyles.filter(heightEm: false) != const KatexSpanStyles()) { + final vlistStyles = _parseInlineStyles(vlist); + if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { throw _KatexHtmlParseError(); } From b58379308ffbf7d19f81e92b723ad80ad7b50ff2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 00:04:26 -0700 Subject: [PATCH 321/423] katex [nfc]: Get pstrut height directly, without making KatexSpanStyles --- lib/model/katex.dart | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 024d7c9b4f..64bdb4c835 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -314,14 +314,11 @@ class _KatexParser { styles = styles.filter(topEm: false); - final pstrutStyles = _parseSpanInlineStyles(pstrutSpan); + final pstrutStyles = _parseInlineStyles(pstrutSpan); if (pstrutStyles == null) throw _KatexHtmlParseError(); - if (pstrutStyles.filter(heightEm: false) - != const KatexSpanStyles()) { - throw _KatexHtmlParseError(); - } - final pstrutHeightEm = pstrutStyles.heightEm; + final pstrutHeightEm = _takeStyleEm(pstrutStyles, 'height'); if (pstrutHeightEm == null) throw _KatexHtmlParseError(); + if (pstrutStyles.isNotEmpty) throw _KatexHtmlParseError(); final KatexSpanNode innerSpanNode; From c14bfe61019874063d9e421fd418789b1dc31c4d Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 16:05:40 -0700 Subject: [PATCH 322/423] katex [nfc]: Directly handle expected inline styles on vlist child There's only a handful of specific properties we expect to see on this type of span; so handle those explicitly. In fact, making this list explicit brings to light that there's one property here which doesn't actually appear on KaTeX's vlist children: height. We'll cut that in a separate non-NFC commit. This will also open up ways to simplify the interaction between this and the margin-handling logic below. --- lib/model/katex.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 64bdb4c835..5ef810d092 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -308,11 +308,15 @@ class _KatexParser { 'vlist inner span: ${innerSpan.className}'); } - var styles = _parseSpanInlineStyles(innerSpan); - if (styles == null) throw _KatexHtmlParseError(); - final topEm = styles.topEm ?? 0; - - styles = styles.filter(topEm: false); + final inlineStyles = _parseInlineStyles(innerSpan); + if (inlineStyles == null) throw _KatexHtmlParseError(); + final styles = KatexSpanStyles( + heightEm: _takeStyleEm(inlineStyles, 'height'), + marginLeftEm: _takeStyleEm(inlineStyles, 'margin-left'), + marginRightEm: _takeStyleEm(inlineStyles, 'margin-right'), + ); + final topEm = _takeStyleEm(inlineStyles, 'top'); + if (inlineStyles.isNotEmpty) throw _KatexHtmlParseError(); final pstrutStyles = _parseInlineStyles(pstrutSpan); if (pstrutStyles == null) throw _KatexHtmlParseError(); @@ -345,7 +349,7 @@ class _KatexParser { } rows.add(KatexVlistRowNode( - verticalOffsetEm: topEm + pstrutHeightEm, + verticalOffsetEm: (topEm ?? 0) + pstrutHeightEm, debugHtmlNode: kDebugMode ? innerSpan : null, node: innerSpanNode)); } else { From 73ae26991c86967b29bf465953b1e3ea74f36701 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 17:51:10 -0700 Subject: [PATCH 323/423] katex: Don't expect height on vlist child spans These spans are highly structured; the only properties that go into their inline styles are top, margin-left, and margin-right. --- lib/model/katex.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 5ef810d092..6717b52da6 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -311,7 +311,6 @@ class _KatexParser { final inlineStyles = _parseInlineStyles(innerSpan); if (inlineStyles == null) throw _KatexHtmlParseError(); final styles = KatexSpanStyles( - heightEm: _takeStyleEm(inlineStyles, 'height'), marginLeftEm: _takeStyleEm(inlineStyles, 'margin-left'), marginRightEm: _takeStyleEm(inlineStyles, 'margin-right'), ); From fe383b839fc0c8174f98f74c8cca414aa93ed7a0 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 00:23:34 -0700 Subject: [PATCH 324/423] katex [nfc]: Consolidate logic for computing overall styles of KatexSpanNode This just pulls these three pieces of closely-related logic next to each other. That will make it easier to refactor them further. This causes one change in the survey script's list of failure reasons: when the `delimcenter` class occurs with an inline `top` property, we now record the unsupported class before reaching the hard fail for the unsupported property. This has no user-visible effect, though, because it can only happen when the expression is going to reach that hard failure either way. --- lib/model/katex.dart | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 6717b52da6..29e5c38505 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -369,15 +369,6 @@ class _KatexParser { } } - final inlineStyles = _parseSpanInlineStyles(element); - if (inlineStyles != null) { - // Currently, we expect `top` to only be inside a vlist, and - // we handle that case separately above. - if (inlineStyles.topEm != null) { - throw _KatexHtmlParseError('unsupported inline CSS property: top'); - } - } - // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. // @@ -621,13 +612,23 @@ class _KatexParser { _hasError = true; } } - final styles = KatexSpanStyles( + final classStyles = KatexSpanStyles( fontFamily: fontFamily, fontSizeEm: fontSizeEm, fontWeight: fontWeight, fontStyle: fontStyle, textAlign: textAlign, ); + final inlineStyles = _parseSpanInlineStyles(element); + if (inlineStyles != null) { + // Currently, we expect `top` to only be inside a vlist, and + // we handle that case separately above. + if (inlineStyles.topEm != null) { + throw _KatexHtmlParseError('unsupported inline CSS property: top'); + } + } + final styles = inlineStyles == null ? classStyles + : classStyles.merge(inlineStyles); String? text; List? spans; @@ -639,9 +640,7 @@ class _KatexParser { if (text == null && spans == null) throw _KatexHtmlParseError(); return KatexSpanNode( - styles: inlineStyles != null - ? styles.merge(inlineStyles) - : styles, + styles: styles, text: text, nodes: spans, debugHtmlNode: debugHtmlNode); From 0f634c9d90911a95409d5576b9f0791fe066c5c6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 00:30:01 -0700 Subject: [PATCH 325/423] katex [nfc]: Inline remaining/main use of _parseSpanInlineStyles And inline the effect of the `merge` method, eliminating that method too. This way we get to construct just one KatexSpanStyles object, rather than constructing three of them when inline styles are present. --- lib/model/katex.dart | 76 +++++++++++--------------------------------- 1 file changed, 18 insertions(+), 58 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 29e5c38505..255e1eaec4 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -612,23 +612,32 @@ class _KatexParser { _hasError = true; } } - final classStyles = KatexSpanStyles( + + final inlineStyles = _parseInlineStyles(element); + final styles = KatexSpanStyles( fontFamily: fontFamily, fontSizeEm: fontSizeEm, fontWeight: fontWeight, fontStyle: fontStyle, textAlign: textAlign, + heightEm: _takeStyleEm(inlineStyles, 'height'), + topEm: _takeStyleEm(inlineStyles, 'top'), + marginLeftEm: _takeStyleEm(inlineStyles, 'margin-left'), + marginRightEm: _takeStyleEm(inlineStyles, 'margin-right'), + // TODO handle more CSS properties ); - final inlineStyles = _parseSpanInlineStyles(element); - if (inlineStyles != null) { - // Currently, we expect `top` to only be inside a vlist, and - // we handle that case separately above. - if (inlineStyles.topEm != null) { - throw _KatexHtmlParseError('unsupported inline CSS property: top'); + if (inlineStyles != null && inlineStyles.isNotEmpty) { + for (final property in inlineStyles.keys) { + assert(debugLog('KaTeX: Unexpected inline CSS property: $property')); + unsupportedInlineCssProperties.add(property); + _hasError = true; } } - final styles = inlineStyles == null ? classStyles - : classStyles.merge(inlineStyles); + // Currently, we expect `top` to only be inside a vlist, and + // we handle that case separately above. + if (styles.topEm != null) { + throw _KatexHtmlParseError('unsupported inline CSS property: top'); + } String? text; List? spans; @@ -646,34 +655,6 @@ class _KatexParser { debugHtmlNode: debugHtmlNode); } - /// Parse the inline CSS styles from the given element, - /// and look for the styles we know how to interpret for a generic KaTeX span. - /// - /// TODO: This has a number of call sites that aren't acting on a generic - /// KaTeX span, but instead on spans in particular roles where we have - /// much more specific expectations on the inline styles. - /// For those, switch to [_parseInlineStyles] and inspect the styles directly. - KatexSpanStyles? _parseSpanInlineStyles(dom.Element element) { - final declarations = _parseInlineStyles(element); - if (declarations == null) return null; - - final result = KatexSpanStyles( - heightEm: _takeStyleEm(declarations, 'height'), - topEm: _takeStyleEm(declarations, 'top'), - marginRightEm: _takeStyleEm(declarations, 'margin-right'), - marginLeftEm: _takeStyleEm(declarations, 'margin-left'), - // TODO handle more CSS properties - ); - - for (final property in declarations.keys) { - assert(debugLog('KaTeX: Unexpected inline CSS property: $property')); - unsupportedInlineCssProperties.add(property); - _hasError = true; - } - - return result; - } - /// Parse the inline CSS styles from the given element. /// /// To interpret the resulting map, consider [_takeStyleEm]. @@ -820,27 +801,6 @@ class KatexSpanStyles { 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, - topEm: other.topEm ?? topEm, - marginRightEm: other.marginRightEm ?? marginRightEm, - marginLeftEm: other.marginLeftEm ?? marginLeftEm, - fontFamily: other.fontFamily ?? fontFamily, - fontSizeEm: other.fontSizeEm ?? fontSizeEm, - fontStyle: other.fontStyle ?? fontStyle, - fontWeight: other.fontWeight ?? fontWeight, - textAlign: other.textAlign ?? textAlign, - ); - } - KatexSpanStyles filter({ bool heightEm = true, bool verticalAlignEm = true, From f54d168a2e724b2ed292f88c1e5651ae96b86b0f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 12:12:42 -0700 Subject: [PATCH 326/423] katex [nfc]: Construct vlist child's styles directly, without filter --- lib/model/katex.dart | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 255e1eaec4..e871943e91 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -310,9 +310,13 @@ class _KatexParser { final inlineStyles = _parseInlineStyles(innerSpan); if (inlineStyles == null) throw _KatexHtmlParseError(); + final marginLeftEm = _takeStyleEm(inlineStyles, 'margin-left'); + final marginLeftIsNegative = marginLeftEm?.isNegative ?? false; + final marginRightEm = _takeStyleEm(inlineStyles, 'margin-right'); + if (marginRightEm?.isNegative ?? false) throw _KatexHtmlParseError(); final styles = KatexSpanStyles( - marginLeftEm: _takeStyleEm(inlineStyles, 'margin-left'), - marginRightEm: _takeStyleEm(inlineStyles, 'margin-right'), + marginLeftEm: marginLeftIsNegative ? null : marginLeftEm, + marginRightEm: marginRightEm, ); final topEm = _takeStyleEm(inlineStyles, 'top'); if (inlineStyles.isNotEmpty) throw _KatexHtmlParseError(); @@ -325,19 +329,14 @@ class _KatexParser { final KatexSpanNode innerSpanNode; - final marginRightEm = styles.marginRightEm; - final marginLeftEm = styles.marginLeftEm; - if (marginRightEm != null && marginRightEm.isNegative) { - throw _KatexHtmlParseError(); - } - if (marginLeftEm != null && marginLeftEm.isNegative) { + if (marginLeftIsNegative) { innerSpanNode = KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [KatexNegativeMarginNode( - leftOffsetEm: marginLeftEm, + leftOffsetEm: marginLeftEm!, nodes: [KatexSpanNode( - styles: styles.filter(marginLeftEm: false), + styles: styles, text: null, nodes: _parseChildSpans(otherSpans))])]); } else { From ebce0a4b0fe95fa1cb414721ccc91cdca097beb3 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 15:35:38 -0700 Subject: [PATCH 327/423] katex [nfc]: Dedupe logic for vlist child between margin/no-margin cases --- lib/model/katex.dart | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index e871943e91..b58abe2a97 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -327,29 +327,24 @@ class _KatexParser { if (pstrutHeightEm == null) throw _KatexHtmlParseError(); if (pstrutStyles.isNotEmpty) throw _KatexHtmlParseError(); - final KatexSpanNode innerSpanNode; + KatexSpanNode child = KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)); if (marginLeftIsNegative) { - innerSpanNode = KatexSpanNode( + child = KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [KatexNegativeMarginNode( leftOffsetEm: marginLeftEm!, - nodes: [KatexSpanNode( - styles: styles, - text: null, - nodes: _parseChildSpans(otherSpans))])]); - } else { - innerSpanNode = KatexSpanNode( - styles: styles, - text: null, - nodes: _parseChildSpans(otherSpans)); + nodes: [child])]); } rows.add(KatexVlistRowNode( verticalOffsetEm: (topEm ?? 0) + pstrutHeightEm, debugHtmlNode: kDebugMode ? innerSpan : null, - node: innerSpanNode)); + node: child)); } else { throw _KatexHtmlParseError(); } From 2a604bacd162ab3246045ff3aad64b7a132cd483 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 17:53:47 -0700 Subject: [PATCH 328/423] katex [nfc]: Note that heightEm might turn out not to be needed --- lib/model/katex.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index b58abe2a97..98fc69a861 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -723,6 +723,10 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { + // TODO(#1674) does height actually appear on generic spans? + // In a corpus, the only occurrences that we don't already handle separately + // (i.e. occurrences other than on struts, vlists, etc) seem to be within + // accents; so after #1674 we might be handling those separately too. final double? heightEm; // We expect `vertical-align` inline style to be only present on a From 39fdded9b75c39b3103b7fd92eae6c8fc77391ce Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 21:36:52 -0700 Subject: [PATCH 329/423] katex [nfc]: Simplify extracting from attrs map in _parseInlineStyles This map pattern syntax looks an awful lot like it's saying that no other keys should be present -- after all, that's what the corresponding syntax in a list pattern would mean. So I initially read this to mean that this code would ignore the inline styles if any other attribute was present on the element; which wouldn't be desirable logic. In fact it's just saying that this one key should be present and match the given pattern. But there are simpler ways to say that; so use one. --- lib/model/katex.dart | 46 ++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 98fc69a861..bf6fdad37e 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -653,32 +653,32 @@ class _KatexParser { /// /// To interpret the resulting map, consider [_takeStyleEm]. static Map? _parseInlineStyles(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 ruleSet]) { - final result = {}; - for (final declaration in ruleSet.declarationGroup.declarations) { - if (declaration case css_visitor.Declaration( - :final property, - expression: css_visitor.Expressions( - expressions: [css_visitor.Expression() && final expression]), - )) { - result.update(property, ifAbsent: () => expression, - (_) => throw _KatexHtmlParseError( - 'duplicate inline CSS property: $property')); - } else { - throw _KatexHtmlParseError('unexpected shape of inline CSS'); - } + final styleStr = element.attributes['style']; + if (styleStr == null) return null; + + // `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 ruleSet]) { + final result = {}; + for (final declaration in ruleSet.declarationGroup.declarations) { + if (declaration case css_visitor.Declaration( + :final property, + expression: css_visitor.Expressions( + expressions: [css_visitor.Expression() && final expression]), + )) { + result.update(property, ifAbsent: () => expression, + (_) => throw _KatexHtmlParseError( + 'duplicate inline CSS property: $property')); + } else { + throw _KatexHtmlParseError('unexpected shape of inline CSS'); } - return result; - } else { - throw _KatexHtmlParseError(); } + return result; + } else { + throw _KatexHtmlParseError(); } - return null; } /// Remove the given property from the given style map, From 83a0b6fa15a02196584b870aa186f40460c07c65 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 22:18:39 -0700 Subject: [PATCH 330/423] katex [nfc]: Split out _parseStrut, _parseVlist, _parseGenericSpan Each of these swathes of logic has no interaction with the others. Splitting them into their own methods makes that structure easy for the reader to see. --- lib/model/katex.dart | 264 +++++++++++++++++++++++-------------------- 1 file changed, 141 insertions(+), 123 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index bf6fdad37e..6eab8473c7 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -219,149 +219,167 @@ class _KatexParser { return resultSpans; } - static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); - static final _sizeClassRegExp = RegExp(r'^size(\d\d?)$'); - KatexNode _parseSpan(dom.Element element) { + assert(element.localName == 'span'); // TODO maybe check if the sequence of ancestors matter for spans. - final debugHtmlNode = kDebugMode ? element : null; - if (element.className == 'strut') { - if (element.nodes.isNotEmpty) throw _KatexHtmlParseError(); - - final styles = _parseInlineStyles(element); - if (styles == null) throw _KatexHtmlParseError(); - final heightEm = _takeStyleEm(styles, 'height'); - if (heightEm == null) throw _KatexHtmlParseError(); - final verticalAlignEm = _takeStyleEm(styles, 'vertical-align'); - if (styles.isNotEmpty) throw _KatexHtmlParseError(); - - return KatexStrutNode( - heightEm: heightEm, - verticalAlignEm: verticalAlignEm, - debugHtmlNode: debugHtmlNode); + return _parseStrut(element); } if (element.className == 'vlist-t' || element.className == 'vlist-t vlist-t2') { - final vlistT = element; - if (vlistT.nodes.isEmpty) throw _KatexHtmlParseError(); - if (vlistT.attributes.containsKey('style')) throw _KatexHtmlParseError(); - - final hasTwoVlistR = vlistT.className == 'vlist-t vlist-t2'; - if (!hasTwoVlistR && vlistT.nodes.length != 1) throw _KatexHtmlParseError(); - - if (hasTwoVlistR) { - if (vlistT.nodes case [ - _, - dom.Element(localName: 'span', className: 'vlist-r', nodes: [ - dom.Element(localName: 'span', className: 'vlist', nodes: [ - dom.Element(localName: 'span', className: '', nodes: []), - ]) && final vlist, - ]), - ]) { - // In the generated HTML the .vlist in second .vlist-r span will have - // a "height" inline style which we ignore, because it doesn't seem - // to have any effect in rendering on the web. - // But also make sure there aren't any other inline styles present. - final vlistStyles = _parseInlineStyles(vlist); - if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { - throw _KatexHtmlParseError(); - } - } else { + return _parseVlist(element); + } + + return _parseGenericSpan(element); + } + + KatexNode _parseStrut(dom.Element element) { + assert(element.localName == 'span'); + assert(element.className == 'strut'); + if (element.nodes.isNotEmpty) throw _KatexHtmlParseError(); + + final styles = _parseInlineStyles(element); + if (styles == null) throw _KatexHtmlParseError(); + final heightEm = _takeStyleEm(styles, 'height'); + if (heightEm == null) throw _KatexHtmlParseError(); + final verticalAlignEm = _takeStyleEm(styles, 'vertical-align'); + if (styles.isNotEmpty) throw _KatexHtmlParseError(); + + return KatexStrutNode( + heightEm: heightEm, + verticalAlignEm: verticalAlignEm, + debugHtmlNode: kDebugMode ? element : null); + } + + KatexNode _parseVlist(dom.Element element) { + assert(element.localName == 'span'); + assert(element.className == 'vlist-t' + || element.className == 'vlist-t vlist-t2'); + final vlistT = element; + if (vlistT.nodes.isEmpty) throw _KatexHtmlParseError(); + if (vlistT.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + final hasTwoVlistR = vlistT.className == 'vlist-t vlist-t2'; + if (!hasTwoVlistR && vlistT.nodes.length != 1) throw _KatexHtmlParseError(); + + if (hasTwoVlistR) { + if (vlistT.nodes case [ + _, + dom.Element(localName: 'span', className: 'vlist-r', nodes: [ + dom.Element(localName: 'span', className: 'vlist', nodes: [ + dom.Element(localName: 'span', className: '', nodes: []), + ]) && final vlist, + ]), + ]) { + // In the generated HTML the .vlist in second .vlist-r span will have + // a "height" inline style which we ignore, because it doesn't seem + // to have any effect in rendering on the web. + // But also make sure there aren't any other inline styles present. + final vlistStyles = _parseInlineStyles(vlist); + if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { throw _KatexHtmlParseError(); } + } else { + throw _KatexHtmlParseError(); } + } - if (vlistT.nodes.first - case dom.Element(localName: 'span', className: 'vlist-r') && - final vlistR) { - if (vlistR.attributes.containsKey('style')) throw _KatexHtmlParseError(); - - if (vlistR.nodes.first - case dom.Element(localName: 'span', className: 'vlist') && - final vlist) { - // Same as above for the second .vlist-r span, .vlist span in first - // .vlist-r span will have "height" inline style which we ignore, - // because it doesn't seem to have any effect in rendering on - // the web. - // But also make sure there aren't any other inline styles present. - final vlistStyles = _parseInlineStyles(vlist); - if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { - throw _KatexHtmlParseError(); - } + if (vlistT.nodes.first + case dom.Element(localName: 'span', className: 'vlist-r') && + final vlistR) { + if (vlistR.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + if (vlistR.nodes.first + case dom.Element(localName: 'span', className: 'vlist') && + final vlist) { + // Same as above for the second .vlist-r span, .vlist span in first + // .vlist-r span will have "height" inline style which we ignore, + // because it doesn't seem to have any effect in rendering on + // the web. + // But also make sure there aren't any other inline styles present. + final vlistStyles = _parseInlineStyles(vlist); + if (vlistStyles != null && vlistStyles.keys.any((p) => p != 'height')) { + throw _KatexHtmlParseError(); + } + + final rows = []; + + for (final innerSpan in vlist.nodes) { + if (innerSpan case dom.Element( + localName: 'span', + nodes: [ + dom.Element(localName: 'span', className: 'pstrut') && + final pstrutSpan, + ...final otherSpans, + ], + )) { + if (innerSpan.className != '') { + throw _KatexHtmlParseError('unexpected CSS class for ' + 'vlist inner span: ${innerSpan.className}'); + } - final rows = []; - - for (final innerSpan in vlist.nodes) { - if (innerSpan case dom.Element( - localName: 'span', - nodes: [ - dom.Element(localName: 'span', className: 'pstrut') && - final pstrutSpan, - ...final otherSpans, - ], - )) { - if (innerSpan.className != '') { - throw _KatexHtmlParseError('unexpected CSS class for ' - 'vlist inner span: ${innerSpan.className}'); - } - - final inlineStyles = _parseInlineStyles(innerSpan); - if (inlineStyles == null) throw _KatexHtmlParseError(); - final marginLeftEm = _takeStyleEm(inlineStyles, 'margin-left'); - final marginLeftIsNegative = marginLeftEm?.isNegative ?? false; - final marginRightEm = _takeStyleEm(inlineStyles, 'margin-right'); - if (marginRightEm?.isNegative ?? false) throw _KatexHtmlParseError(); - final styles = KatexSpanStyles( - marginLeftEm: marginLeftIsNegative ? null : marginLeftEm, - marginRightEm: marginRightEm, - ); - final topEm = _takeStyleEm(inlineStyles, 'top'); - if (inlineStyles.isNotEmpty) throw _KatexHtmlParseError(); - - final pstrutStyles = _parseInlineStyles(pstrutSpan); - if (pstrutStyles == null) throw _KatexHtmlParseError(); - final pstrutHeightEm = _takeStyleEm(pstrutStyles, 'height'); - if (pstrutHeightEm == null) throw _KatexHtmlParseError(); - if (pstrutStyles.isNotEmpty) throw _KatexHtmlParseError(); - - KatexSpanNode child = KatexSpanNode( - styles: styles, + final inlineStyles = _parseInlineStyles(innerSpan); + if (inlineStyles == null) throw _KatexHtmlParseError(); + final marginLeftEm = _takeStyleEm(inlineStyles, 'margin-left'); + final marginLeftIsNegative = marginLeftEm?.isNegative ?? false; + final marginRightEm = _takeStyleEm(inlineStyles, 'margin-right'); + if (marginRightEm?.isNegative ?? false) throw _KatexHtmlParseError(); + final styles = KatexSpanStyles( + marginLeftEm: marginLeftIsNegative ? null : marginLeftEm, + marginRightEm: marginRightEm, + ); + final topEm = _takeStyleEm(inlineStyles, 'top'); + if (inlineStyles.isNotEmpty) throw _KatexHtmlParseError(); + + final pstrutStyles = _parseInlineStyles(pstrutSpan); + if (pstrutStyles == null) throw _KatexHtmlParseError(); + final pstrutHeightEm = _takeStyleEm(pstrutStyles, 'height'); + if (pstrutHeightEm == null) throw _KatexHtmlParseError(); + if (pstrutStyles.isNotEmpty) throw _KatexHtmlParseError(); + + KatexSpanNode child = KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)); + + if (marginLeftIsNegative) { + child = KatexSpanNode( + styles: KatexSpanStyles(), text: null, - nodes: _parseChildSpans(otherSpans)); - - if (marginLeftIsNegative) { - child = KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [KatexNegativeMarginNode( - leftOffsetEm: marginLeftEm!, - nodes: [child])]); - } - - rows.add(KatexVlistRowNode( - verticalOffsetEm: (topEm ?? 0) + pstrutHeightEm, - debugHtmlNode: kDebugMode ? innerSpan : null, - node: child)); - } else { - throw _KatexHtmlParseError(); + nodes: [KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm!, + nodes: [child])]); } - } - // TODO(#1716) Handle styling for .vlist-t2 spans - return KatexVlistNode( - rows: rows, - debugHtmlNode: debugHtmlNode, - ); - } else { - throw _KatexHtmlParseError(); + rows.add(KatexVlistRowNode( + verticalOffsetEm: (topEm ?? 0) + pstrutHeightEm, + debugHtmlNode: kDebugMode ? innerSpan : null, + node: child)); + } else { + throw _KatexHtmlParseError(); + } } + + // TODO(#1716) Handle styling for .vlist-t2 spans + return KatexVlistNode( + rows: rows, + debugHtmlNode: kDebugMode ? element : null, + ); } else { throw _KatexHtmlParseError(); } + } else { + throw _KatexHtmlParseError(); } + } + + static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); + static final _sizeClassRegExp = RegExp(r'^size(\d\d?)$'); + + KatexNode _parseGenericSpan(dom.Element element) { + assert(element.localName == 'span'); // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. @@ -646,7 +664,7 @@ class _KatexParser { styles: styles, text: text, nodes: spans, - debugHtmlNode: debugHtmlNode); + debugHtmlNode: kDebugMode ? element : null); } /// Parse the inline CSS styles from the given element. From 6304ea69e31e9f7024134a588282d97fc8f19e62 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 18 Jul 2025 16:48:34 -0700 Subject: [PATCH 331/423] presence: Dispose Presence object when store disposed I happened to notice this message getting printed repeatedly in the debug logs (reformatted a bit): [ERROR:flutter/runtime/dart_vm_initializer.cc(40)] Unhandled Exception: NetworkException: HTTP request failed. Client is already closed. (ClientException: HTTP request failed. Client is already closed., uri=https://chat.zulip.org/api/v1/users/me/presence) #0 ApiConnection.send (package:zulip/api/core.dart:175) #1 Presence._maybePingAndRecordResponse (package:zulip/model/presence.dart:93) #2 Presence._poll (package:zulip/model/presence.dart:121) That'd be a symptom of an old Presence continuing to run its polling loop after the ApiConnection has been closed, which happens when the PerAccountStore is disposed. Looks like when we introduced Presence in 5d43df2be (#1619), we forgot to call its `dispose` method. Fix that now. The presence model doesn't currently have any tests. So rather than try to add a test for just this, we'll leave it as something to include when we write those tests, #1620. --- lib/model/store.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/model/store.dart b/lib/model/store.dart index 9973cbc33e..7b48147f12 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -878,6 +878,7 @@ class PerAccountStore extends PerAccountStoreBase with recentDmConversationsView.dispose(); unreads.dispose(); _messages.dispose(); + presence.dispose(); typingStatus.dispose(); typingNotifier.dispose(); updateMachine?.dispose(); From 9297b75a3ce8b46c8fb41c7dd65139ac1a03820f Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Fri, 18 Jul 2025 05:24:13 +0200 Subject: [PATCH 332/423] i18n: Sync translations from Weblate. --- assets/l10n/app_ar.arb | 11 +- assets/l10n/app_ja.arb | 174 +++++++++++++++++- assets/l10n/app_pl.arb | 12 ++ assets/l10n/app_ru.arb | 12 ++ assets/l10n/app_zh_Hant_TW.arb | 12 ++ .../l10n/zulip_localizations_ja.dart | 79 ++++---- .../l10n/zulip_localizations_pl.dart | 6 +- .../l10n/zulip_localizations_ru.dart | 6 +- .../l10n/zulip_localizations_zh.dart | 9 + 9 files changed, 272 insertions(+), 49 deletions(-) diff --git a/assets/l10n/app_ar.arb b/assets/l10n/app_ar.arb index 5ca1208723..ff082c9a15 100644 --- a/assets/l10n/app_ar.arb +++ b/assets/l10n/app_ar.arb @@ -1,11 +1,20 @@ { "wildcardMentionAll": "الجميع", + "@wildcardMentionAll": {}, "wildcardMentionEveryone": "الكل", + "@wildcardMentionEveryone": {}, "wildcardMentionChannel": "القناة", + "@wildcardMentionChannel": {}, "wildcardMentionStream": "الدفق", + "@wildcardMentionStream": {}, "wildcardMentionTopic": "الموضوع", + "@wildcardMentionTopic": {}, "wildcardMentionChannelDescription": "إخطار القناة", + "@wildcardMentionChannelDescription": {}, "wildcardMentionStreamDescription": "إخطار الدفق", + "@wildcardMentionStreamDescription": {}, "wildcardMentionAllDmDescription": "إخطار المستلمين", - "wildcardMentionTopicDescription": "إخطار الموضوع" + "@wildcardMentionAllDmDescription": {}, + "wildcardMentionTopicDescription": "إخطار الموضوع", + "@wildcardMentionTopicDescription": {} } diff --git a/assets/l10n/app_ja.arb b/assets/l10n/app_ja.arb index a66aede69e..dcbe99f1b7 100644 --- a/assets/l10n/app_ja.arb +++ b/assets/l10n/app_ja.arb @@ -16,5 +16,177 @@ "userRoleGuest": "ゲスト", "@userRoleGuest": {}, "userRoleUnknown": "不明", - "@userRoleUnknown": {} + "@userRoleUnknown": {}, + "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" + }, + "upgradeWelcomeDialogTitle": "新しいZulipアプリへようこそ!", + "@upgradeWelcomeDialogTitle": { + "description": "Title for dialog shown on first upgrade from the legacy Zulip app." + }, + "aboutPageTapToView": "タップして表示", + "@aboutPageTapToView": { + "description": "Item subtitle in About Zulip page to navigate to Licenses page" + }, + "upgradeWelcomeDialogMessage": "より速く、洗練されたデザインで、これまでと同じ使い心地をお楽しみいただけます。", + "@upgradeWelcomeDialogMessage": { + "description": "Message text 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." + }, + "settingsPageTitle": "設定", + "@settingsPageTitle": { + "description": "Title for the settings page." + }, + "switchAccountButton": "アカウントを切り替える", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "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." + }, + "actionSheetOptionListOfTopics": "トピック一覧", + "@actionSheetOptionListOfTopics": { + "description": "Label for navigating to a channel's topic-list page." + }, + "actionSheetOptionMarkChannelAsRead": "チャンネルを既読にする", + "@actionSheetOptionMarkChannelAsRead": { + "description": "Label for marking a channel as read." + }, + "actionSheetOptionMuteTopic": "トピックをミュート", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "トピックのミュートを解除", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionFollowTopic": "トピックをフォロー", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "actionSheetOptionUnfollowTopic": "トピックのフォローを解除", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, + "actionSheetOptionResolveTopic": "解決済みにする", + "@actionSheetOptionResolveTopic": { + "description": "Label for the 'Mark as resolved' button on the topic 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." + }, + "actionSheetOptionHideMutedMessage": "ミュートしたメッセージを再び非表示にする", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, + "actionSheetOptionShare": "共有", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionQuoteMessage": "メッセージを引用", + "@actionSheetOptionQuoteMessage": { + "description": "Label for the 'Quote message' button in the message 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)." + }, + "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." + }, + "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" + } + } + } } diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 5173de210c..1350cf82a4 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1212,5 +1212,17 @@ "searchMessagesClearButtonTooltip": "Wyczyść", "@searchMessagesClearButtonTooltip": { "description": "Tooltip for the 'x' button in the search text field." + }, + "invisibleMode": "Tryb ukrycia", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "Problem z włączeniem trybu ukrycia. Spróbuj ponownie.", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "Problem z wyłączeniem trybu ukrycia. Spróbuj ponownie.", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index ed33099077..d1cd596e2b 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1212,5 +1212,17 @@ "revealButtonLabel": "Показать сообщение", "@revealButtonLabel": { "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "invisibleMode": "Режим невидимости", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "Не удалось включить режим невидимости. Повторите попытку позже.", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "Не удалось отключить режим невидимости. Повторите попытку позже.", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." } } diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb index 7fe1f37b91..b05167206a 100644 --- a/assets/l10n/app_zh_Hant_TW.arb +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -586,5 +586,17 @@ "combinedFeedPageTitle": "綜合饋給", "@combinedFeedPageTitle": { "description": "Page title for the 'Combined feed' message view." + }, + "searchMessagesPageTitle": "搜尋", + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "searchMessagesHintText": "搜尋", + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "searchMessagesClearButtonTooltip": "清除", + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." } } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index ff27eaee8b..edf5c759f9 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -9,39 +9,38 @@ class ZulipLocalizationsJa extends ZulipLocalizations { ZulipLocalizationsJa([String locale = 'ja']) : super(locale); @override - String get aboutPageTitle => 'About Zulip'; + String get aboutPageTitle => 'Zulipについて'; @override - String get aboutPageAppVersion => 'App version'; + String get aboutPageAppVersion => 'アプリのバージョン'; @override - String get aboutPageOpenSourceLicenses => 'Open-source licenses'; + String get aboutPageOpenSourceLicenses => 'オープンソースライセンス'; @override - String get aboutPageTapToView => 'Tap to view'; + 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 => 'アカウントを選択'; @override - String get settingsPageTitle => 'Settings'; + String get settingsPageTitle => '設定'; @override - String get switchAccountButton => 'Switch account'; + String get switchAccountButton => 'アカウントを切り替える'; @override String tryAnotherAccountMessage(Object url) { @@ -52,10 +51,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get tryAnotherAccountButton => 'Try another account'; @override - String get chooseAccountPageLogOutButton => 'Log out'; + String get chooseAccountPageLogOutButton => 'ログアウト'; @override - String get logOutConfirmationDialogTitle => 'Log out?'; + String get logOutConfirmationDialogTitle => 'ログアウトしますか?'; @override String get logOutConfirmationDialogMessage => @@ -88,74 +87,73 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 'To upload files, please grant Zulip additional permissions in Settings.'; @override - String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; + String get actionSheetOptionMarkChannelAsRead => 'チャンネルを既読にする'; @override - String get actionSheetOptionListOfTopics => 'List of topics'; + String get actionSheetOptionListOfTopics => 'トピック一覧'; @override - String get actionSheetOptionMuteTopic => 'Mute topic'; + String get actionSheetOptionMuteTopic => 'トピックをミュート'; @override - String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + String get actionSheetOptionUnmuteTopic => 'トピックのミュートを解除'; @override - String get actionSheetOptionFollowTopic => 'Follow topic'; + String get actionSheetOptionFollowTopic => 'トピックをフォロー'; @override - String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + String get actionSheetOptionUnfollowTopic => 'トピックのフォローを解除'; @override - String get actionSheetOptionResolveTopic => 'Mark as resolved'; + String get actionSheetOptionResolveTopic => '解決済みにする'; @override - String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + String get actionSheetOptionUnresolveTopic => '未解決にする'; @override - String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; + String get errorResolveTopicFailedTitle => 'トピックを解決済みにできませんでした'; @override - String get errorUnresolveTopicFailedTitle => - 'Failed to mark topic as unresolved'; + String get errorUnresolveTopicFailedTitle => 'トピックを未解決にできませんでした'; @override - String get actionSheetOptionCopyMessageText => 'Copy message text'; + String get actionSheetOptionCopyMessageText => 'メッセージ本文をコピー'; @override - String get actionSheetOptionCopyMessageLink => 'Copy link to message'; + String get actionSheetOptionCopyMessageLink => 'メッセージへのリンクをコピー'; @override - String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + String get actionSheetOptionMarkAsUnread => 'ここから未読にする'; @override - String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + String get actionSheetOptionHideMutedMessage => 'ミュートしたメッセージを再び非表示にする'; @override - String get actionSheetOptionShare => 'Share'; + String get actionSheetOptionShare => '共有'; @override - String get actionSheetOptionQuoteMessage => 'Quote message'; + String get actionSheetOptionQuoteMessage => 'メッセージを引用'; @override - String get actionSheetOptionStarMessage => 'Star message'; + String get actionSheetOptionStarMessage => 'メッセージにスターを付ける'; @override - String get actionSheetOptionUnstarMessage => 'Unstar message'; + String get actionSheetOptionUnstarMessage => 'メッセージのスターを外す'; @override - String get actionSheetOptionEditMessage => 'Edit message'; + String get actionSheetOptionEditMessage => 'メッセージを編集'; @override - String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + String get actionSheetOptionMarkTopicAsRead => 'トピックを既読にする'; @override - String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; + String get errorWebAuthOperationalErrorTitle => '問題が発生しました'; @override - String get errorWebAuthOperationalError => 'An unexpected error occurred.'; + String get errorWebAuthOperationalError => '予期しないエラーが発生しました。'; @override - String get errorAccountLoggedInTitle => 'Account already logged in'; + String get errorAccountLoggedInTitle => 'このアカウントはすでにログインしています'; @override String errorAccountLoggedIn(String email, String server) { @@ -163,15 +161,14 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorCouldNotFetchMessageSource => - 'Could not fetch message source.'; + String get errorCouldNotFetchMessageSource => 'メッセージのソースを取得できませんでした。'; @override - String get errorCopyingFailed => 'Copying failed'; + String get errorCopyingFailed => 'コピーに失敗しました'; @override String errorFailedToUploadFileTitle(String filename) { - return 'Failed to upload file: $filename'; + return 'ファイルのアップロードに失敗しました: $filename'; } @override diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0e9cf379b6..c96ab24679 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -647,15 +647,15 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get yesterday => 'Wczoraj'; @override - String get invisibleMode => 'Invisible mode'; + String get invisibleMode => 'Tryb ukrycia'; @override String get turnOnInvisibleModeErrorTitle => - 'Error turning on invisible mode. Please try again.'; + 'Problem z włączeniem trybu ukrycia. Spróbuj ponownie.'; @override String get turnOffInvisibleModeErrorTitle => - 'Error turning off invisible mode. Please try again.'; + 'Problem z wyłączeniem trybu ukrycia. Spróbuj ponownie.'; @override String get userRoleOwner => 'Właściciel'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 1349d79baa..be5de60e97 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -650,15 +650,15 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get yesterday => 'Вчера'; @override - String get invisibleMode => 'Invisible mode'; + String get invisibleMode => 'Режим невидимости'; @override String get turnOnInvisibleModeErrorTitle => - 'Error turning on invisible mode. Please try again.'; + 'Не удалось включить режим невидимости. Повторите попытку позже.'; @override String get turnOffInvisibleModeErrorTitle => - 'Error turning off invisible mode. Please try again.'; + 'Не удалось отключить режим невидимости. Повторите попытку позже.'; @override String get userRoleOwner => 'Владелец'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index b7eaba478f..8f88549383 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -2076,6 +2076,15 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get userRoleGuest => '訪客'; + @override + String get searchMessagesPageTitle => '搜尋'; + + @override + String get searchMessagesHintText => '搜尋'; + + @override + String get searchMessagesClearButtonTooltip => '清除'; + @override String get inboxPageTitle => '收件匣'; From d6a5020f1eed90b15f311457404aaea77801d298 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 18:12:02 -0700 Subject: [PATCH 333/423] action_sheet test: Avoid mutating shared example users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the same sort of state leak that caused #1712; this instance of it just hasn't happened to break any tests for us yet. We'll soon arrange things so that this sort of state-leaking mutation causes an immediate error. This is one of the three total places where it turns out we had such mutations, including the one we just fixed in a04b44ede (#1713). The second of these tests ("no error if recipient was deactivated …") wasn't actually mutating the shared example user `eg.otherUser`, because secretly `setupToMessageActionSheet` makes a new User object with the same user ID and puts that in the store. Still, it *looked* like it was; best to do something that clearly looks correct instead. The first of these tests was indeed mutating `eg.selfUser`, just as it looks like it's doing. --- test/widgets/action_sheet_test.dart | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 177aa43503..bc9b53711f 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -55,6 +55,7 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + User? selfUser, User? sender, List? mutedUserIds, bool? realmAllowMessageEditing, @@ -67,15 +68,17 @@ Future setupToMessageActionSheet(WidgetTester tester, { // TODO(#1667) will be null in a search narrow; remove `!`. assert(narrow.containsMessage(message)!); + selfUser ??= eg.selfUser; + final selfAccount = eg.account(user: selfUser); await testBinding.globalStore.add( - eg.selfAccount, + selfAccount, eg.initialSnapshot( realmAllowMessageEditing: realmAllowMessageEditing, realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, )); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(selfAccount.id); await store.addUsers([ - eg.selfUser, + selfUser, sender ?? eg.user(userId: message.senderId), if (narrow is DmNarrow) ...narrow.otherRecipientIds.map((id) => eg.user(userId: id)), @@ -97,7 +100,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, child: MessageListPage(initNarrow: narrow))); // global store, per-account store, and message list get loaded @@ -1204,11 +1207,13 @@ void main() { }); testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { + final selfUser = eg.user(role: UserRole.member); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + await setupToMessageActionSheet(tester, selfUser: selfUser, + message: message, narrow: TopicNarrow.ofMessage(message)); - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: selfUser.userId, role: UserRole.guest)); await store.handleEvent(eg.channelUpdateEvent(stream, property: ChannelPropertyName.channelPostPolicy, @@ -1240,7 +1245,8 @@ void main() { }); testWidgets('no error if recipient was deactivated while raw-content request in progress', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + final otherUser = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [otherUser]); await setupToMessageActionSheet(tester, message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); @@ -1253,7 +1259,7 @@ void main() { await tapQuoteAndReplyButton(tester); await tester.pump(const Duration(seconds: 1)); // message not yet fetched - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.otherUser.userId, + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: otherUser.userId, isActive: false)); await tester.pump(); // no error From 54ac1ddea61bf20b61c8242f0f5ec646b491d4e5 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 18:23:25 -0700 Subject: [PATCH 334/423] recent-dms test: Avoid mutating eg.selfUser Like in the parent commit fixing some action-sheet tests, this is a state leak that just hasn't happened to break any tests for us yet. In this case, the fix can also simplify the interface that this prep helper `setupPage` has with its callers: instead of a specific feature for setting `fullName` on the self-user, the function can take an optional User object for the self-user. Then the individual test case can do whatever it likes to set up that User object. --- .../widgets/recent_dm_conversations_test.dart | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 3eb49f2ca8..e898218e6e 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -27,16 +27,18 @@ import 'test_app.dart'; Future setupPage(WidgetTester tester, { required List dmMessages, required List users, + User? selfUser, List? mutedUserIds, NavigatorObserver? navigatorObserver, - String? newNameForSelfUser, }) async { addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + selfUser ??= eg.selfUser; + final selfAccount = eg.account(user: selfUser); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(selfAccount.id); - await store.addUser(eg.selfUser); + await store.addUser(selfUser); for (final user in users) { await store.addUser(user); } @@ -46,13 +48,8 @@ Future setupPage(WidgetTester tester, { await store.addMessages(dmMessages); - if (newNameForSelfUser != null) { - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, - fullName: newNameForSelfUser)); - } - await tester.pumpWidget(TestZulipApp( - accountId: eg.selfAccount.id, + accountId: selfAccount.id, navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], child: const HomePage())); @@ -187,7 +184,8 @@ void main() { } Future markMessageAsRead(WidgetTester tester, Message message) async { - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final store = await testBinding.globalStore.perAccount( + testBinding.globalStore.accounts.single.id); await store.handleEvent(UpdateMessageFlagsAddEvent( id: 1, flag: MessageFlag.read, all: false, messages: [message.id])); await tester.pump(); @@ -216,18 +214,18 @@ void main() { }); testWidgets('short name takes one line', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: []); const name = 'Short name'; - await setupPage(tester, users: [], dmMessages: [message], - newNameForSelfUser: name); + final selfUser = eg.user(fullName: name); + await setupPage(tester, selfUser: selfUser, users: [], + dmMessages: [eg.dmMessage(from: selfUser, to: [])]); checkTitle(tester, name, 1); }); testWidgets('very long name takes two lines (must be ellipsized)', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: []); const name = 'Long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name'; - await setupPage(tester, users: [], dmMessages: [message], - newNameForSelfUser: name); + final selfUser = eg.user(fullName: name); + await setupPage(tester, selfUser: selfUser, users: [], + dmMessages: [eg.dmMessage(from: selfUser, to: [])]); checkTitle(tester, name, 2); }); From 551554002a6c082a7b417bc03c54a62d6fc2701b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 18:00:53 -0700 Subject: [PATCH 335/423] test: Prevent mutation of shared example users This ensures we don't re-introduce the sort of state leak that caused #1712. In three recent commits we fixed every existing example of this sort of state leak; only three test files had them. I also looked through all the other top-level fields in this file `example_data.dart`. Most are immutable atoms like ints and strings. There are a handful of remaining points of mutability which could in principle allow this sort of state leak; so I'll tighten those up too in the next couple of commits. --- test/example_data.dart | 68 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index fc32781a1e..10b7876c12 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -17,6 +17,7 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; +import 'model/binding.dart'; import 'model/test_store.dart'; import 'stdlib_checks.dart'; @@ -289,24 +290,77 @@ Account account({ ); } -final User selfUser = user(fullName: 'Self User'); +/// A [User] which throws on attempting to mutate any of its fields. +/// +/// We use this to prevent any tests from leaking state through having a +/// [PerAccountStore] (which will be discarded when [TestZulipBinding.reset] +/// is called at the end of the test case) mutate a [User] in its [UserStore] +/// which happens to a value in this file like [selfUser] (which will not be +/// discarded by [TestZulipBinding.reset]). That was the cause of issue #1712. +class _ImmutableUser extends User { + _ImmutableUser.copyUser(User user) : super( + // When adding a field here, be sure to add the corresponding setter below. + userId: user.userId, + deliveryEmail: user.deliveryEmail, + email: user.email, + fullName: user.fullName, + dateJoined: user.dateJoined, + isActive: user.isActive, + isBillingAdmin: user.isBillingAdmin, + isBot: user.isBot, + botType: user.botType, + botOwnerId: user.botOwnerId, + role: user.role, + timezone: user.timezone, + avatarUrl: user.avatarUrl, + avatarVersion: user.avatarVersion, + profileData: user.profileData == null ? null : Map.unmodifiable(user.profileData!), + isSystemBot: user.isSystemBot, + // When adding a field here, be sure to add the corresponding setter below. + ); + + static final Error _error = UnsupportedError( + 'Cannot mutate immutable User.\n' + 'When a test needs to have the store handle an event which will\n' + 'modify a user, use `eg.user()` to make a fresh User object\n' + 'instead of using a shared User object like `eg.selfUser`.'); + + // userId already immutable + @override set deliveryEmail(_) => throw _error; + @override set email(_) => throw _error; + @override set fullName(_) => throw _error; + // dateJoined already immutable + @override set isActive(_) => throw _error; + @override set isBillingAdmin(_) => throw _error; + // isBot already immutable + // botType already immutable + @override set botOwnerId(_) => throw _error; + @override set role(_) => throw _error; + @override set timezone(_) => throw _error; + @override set avatarUrl(_) => throw _error; + @override set avatarVersion(_) => throw _error; + @override set profileData(_) => throw _error; + // isSystemBot already immutable +} + +final User selfUser = _ImmutableUser.copyUser(user(fullName: 'Self User')); +final User otherUser = _ImmutableUser.copyUser(user(fullName: 'Other User')); +final User thirdUser = _ImmutableUser.copyUser(user(fullName: 'Third User')); +final User fourthUser = _ImmutableUser.copyUser(user(fullName: 'Fourth User')); + +// There's no need for an [Account] analogue of [_ImmutableUser], +// because [Account] (which is generated by Drift) is already immutable. final Account selfAccount = account( id: 1001, user: selfUser, apiKey: 'dQcEJWTq3LczosDkJnRTwf31zniGvMrO', // A Zulip API key is 32 digits of base64. ); - -final User otherUser = user(fullName: 'Other User'); final Account otherAccount = account( id: 1002, user: otherUser, apiKey: '6dxT4b73BYpCTU+i4BB9LAKC5h/CufqY', // A Zulip API key is 32 digits of base64. ); -final User thirdUser = user(fullName: 'Third User'); - -final User fourthUser = user(fullName: 'Fourth User'); - //////////////////////////////////////////////////////////////// // Data attached to the self-account on the realm // From 805afacb2cdab06448d4caa68ab360d93c89635f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 20:07:03 -0700 Subject: [PATCH 336/423] test [nfc]: Make all example-data globals final It's unlikely we would make the mistake of accidentally mutating these bindings; but best to rule it out. --- test/example_data.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index 10b7876c12..61c363dde7 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -131,7 +131,7 @@ GetServerSettingsResult serverSettings({ ); } -ServerEmojiData serverEmojiDataPopular = ServerEmojiData(codeToNames: { +final ServerEmojiData serverEmojiDataPopular = ServerEmojiData(codeToNames: { '1f44d': ['+1', 'thumbs_up', 'like'], '1f389': ['tada'], '1f642': ['slight_smile'], @@ -158,7 +158,7 @@ ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { /// /// zulip/zulip@9feba0f16f is a Server 11 commit. // TODO(server-11) can drop this -ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: { +final ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: { '1f44d': ['+1', 'thumbs_up', 'like'], '1f389': ['tada'], '1f642': ['smile'], @@ -502,21 +502,21 @@ UserTopicItem userTopicItem( // Messages, and pieces of messages. // -Reaction unicodeEmojiReaction = Reaction( +final Reaction unicodeEmojiReaction = Reaction( emojiName: 'thumbs_up', emojiCode: '1f44d', reactionType: ReactionType.unicodeEmoji, userId: selfUser.userId, ); -Reaction realmEmojiReaction = Reaction( +final Reaction realmEmojiReaction = Reaction( emojiName: 'twocents', emojiCode: '181', reactionType: ReactionType.realmEmoji, userId: selfUser.userId, ); -Reaction zulipExtraEmojiReaction = Reaction( +final Reaction zulipExtraEmojiReaction = Reaction( emojiName: 'zulip', emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji, From 83a94075d8bade5d243487c0845976d376827a49 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 17 Jul 2025 19:57:36 -0700 Subject: [PATCH 337/423] test: Prevent mutating shared example ServerEmojiData values In principle these are subject to the same sort of state leak we've run into with the shared example User objects, and fixed in recent commits: these are shared global objects, which don't get discarded or reset by `testBinding.reset`, and until this commit they were mutable. The probability that we'd actually end up with such a state leak was low: ServerEmojiData values never normally get mutated in the app's data structures (unlike User values), plus there'll probably only ever be a small number of tests that would have a reason to use these. But after the preceding couple of commits (notably the one introducing _ImmutableUser), these represent the last remaining mutable data in this file's top-level fields. So let's eliminate that too, and get to 100% in eliminating the possibility of the #1712 class of bug. --- test/example_data.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index 61c363dde7..06d6d130c0 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -131,7 +131,14 @@ GetServerSettingsResult serverSettings({ ); } -final ServerEmojiData serverEmojiDataPopular = ServerEmojiData(codeToNames: { +ServerEmojiData _immutableServerEmojiData({ + required Map> codeToNames}) { + return ServerEmojiData( + codeToNames: Map.unmodifiable(codeToNames.map( + (k, v) => MapEntry(k, List.unmodifiable(v))))); +} + +final ServerEmojiData serverEmojiDataPopular = _immutableServerEmojiData(codeToNames: { '1f44d': ['+1', 'thumbs_up', 'like'], '1f389': ['tada'], '1f642': ['slight_smile'], @@ -158,7 +165,7 @@ ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { /// /// zulip/zulip@9feba0f16f is a Server 11 commit. // TODO(server-11) can drop this -final ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: { +final ServerEmojiData serverEmojiDataPopularLegacy = _immutableServerEmojiData(codeToNames: { '1f44d': ['+1', 'thumbs_up', 'like'], '1f389': ['tada'], '1f642': ['smile'], From 459ca983ab14ebab133841b6518a078c4b0ffa39 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 14:01:42 -0700 Subject: [PATCH 338/423] katex test [nfc]: Split parsing tests to their own file, like implementation This way we maintain our usual parallel between the files that tests live in and the files with the code the tests are about. There's more we can do to make use of having a specific class for these. But we'll do that in separate commits, so that this massive move commit is as pure of a move as possible. --- test/model/content_test.dart | 655 +------------------------------ test/model/katex_test.dart | 698 +++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 23 +- 3 files changed, 713 insertions(+), 663 deletions(-) create mode 100644 test/model/katex_test.dart diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 88bd11c66c..bead811079 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -699,647 +699,6 @@ 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 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```', - '

' - '' - '1234567890' - '\\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' - '

', - [ - 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: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 - text: '2', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 - text: '3', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 - text: '4', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 - text: '5', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 - text: '6', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 - text: '7', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 - text: '8', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: '9', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 - text: '0', - nodes: null), - ]), - ]), - ]); - - static const mathBlockKatexNestedSizing = ContentExample( - 'math block; KaTeX nested sizing', - '```math\n\\tiny {1 \\Huge 2}\n```', - '

' - '' - '12' - '\\tiny {1 \\Huge 2}' - '

', - [ - MathBlockNode( - texSource: '\\tiny {1 \\Huge 2}', - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 - text: '2', - nodes: null), - ]), - ]), - ]), - ]); - - 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```', - '

' - '' - '([' - '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊' - '

', - [ - MathBlockNode( - texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '⟨', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), - text: '(', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), - text: '[', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), - text: '⌈', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), - text: '⌊', - nodes: null), - ]), - ]), - ]), - ]); - - static const mathBlockKatexSpace = ContentExample( - 'math block; KaTeX space', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2214883 - '```math\n1:2\n```', - '

' - '' - '1:21:2' - '

', [ - MathBlockNode( - texSource: '1:2', - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode( - heightEm: 0.6444, - verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, - nodes: []), - KatexSpanNode( - styles: KatexSpanStyles(), - text: ':', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, - nodes: []), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode( - heightEm: 0.6444, - verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '2', - nodes: null), - ]), - ]), - ]); - - static const mathBlockKatexSuperscript = ContentExample( - 'math block, KaTeX superscript; single vlist-r, single vertical offset row', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 - '```math\na\'\n```', - '

' - '' - 'a' - 'a'' - '

', [ - MathBlockNode(texSource: 'a\'', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -3.113 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '′', nodes: null), - ]), - ]), - ])), - ]), - ]), - ]), - ]), - ]), - ]); - - static const mathBlockKatexSubscript = ContentExample( - 'math block, KaTeX subscript; two vlist-r, single vertical offset row', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 - '```math\nx_n\n```', - '

' - '' - 'xn' - 'x_n' - '

', [ - MathBlockNode(texSource: 'x_n', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'x', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.55 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'n', nodes: null), - ]), - ])), - ]), - ]), - ]), - ]), - ]), - ]); - - static const mathBlockKatexSubSuperScript = ContentExample( - 'math block, KaTeX subsup script; two vlist-r, multiple vertical offset rows', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 - '```math\n_u^o\n```', - '

' - '' - 'uo' - '_u^o' - '

', [ - MathBlockNode(texSource: "_u^o", nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.453 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'u', nodes: null), - ]), - ])), - KatexVlistRowNode( - verticalOffsetEm: -3.113 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'o', nodes: null), - ]), - ])), - ]), - ]), - ]), - ]), - ]), - ]); - - static const mathBlockKatexRaisebox = ContentExample( - 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 - '```math\na\\raisebox{0.25em}{\$b\$}c\n```', - '

' - '' - 'abc' - 'a\\raisebox{0.25em}{\$b\$}c' - '

', [ - MathBlockNode(texSource: 'a\\raisebox{0.25em}{\$b\$}c', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -3.25 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', nodes: null), - ]), - ])), - ]), - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'c', nodes: null), - ]), - ]), - ]); - - static const mathBlockKatexNegativeMargin = ContentExample( - 'math block, KaTeX negative margin', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 - '```math\n1 \\! 2\n```', - '

' - '' - '1 ⁣21 \\! 2' - '

', [ - MathBlockNode(texSource: '1 \\! 2', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: '1', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '2', nodes: null), - ]), - ]), - ]), - ]); - - static const mathBlockKatexLogo = ContentExample( - 'math block, KaTeX logo', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 - '```math\n\\KaTeX\n```', - '

' - '' - 'KaTeX' - '\\KaTeX' - '

', [ - MathBlockNode(texSource: '\\KaTeX', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'K', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.905 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 - text: 'A', nodes: null), - ]), - ])), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'T', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.7845 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'E', nodes: null), - ]), - ])), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'X', nodes: null), - ]), - ]), - ]), - ]), - ]), - ]), - ]), - ]), - ]); - - static const mathBlockKatexNegativeMarginsOnVlistRow = ContentExample( - 'math block, KaTeX negative margins on a vlist row', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 - '```math\nX_n\n```', - '

' - '' - 'XnX_n' - '

', [ - MathBlockNode(texSource: 'X_n', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - marginRightEm: 0.07847, - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'X', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.55 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'n', nodes: null), - ]), - ]), - ]), - ])), - ]), - ]), - ]), - ]), - ]), - ]); - static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2421,22 +1780,14 @@ void main() async { testParseExample(ContentExample.codeBlockWithUnknownSpanType); testParseExample(ContentExample.codeBlockFollowedByMultipleLineBreaks); + // The math examples in this file are about how math blocks and spans fit + // into the context of a Zulip message. + // For tests going deeper inside KaTeX content, see katex_test.dart. testParseExample(ContentExample.mathBlock); testParseExample(ContentExample.mathBlocksMultipleInParagraph); testParseExample(ContentExample.mathBlockInQuote); testParseExample(ContentExample.mathBlocksMultipleInQuote); testParseExample(ContentExample.mathBlockBetweenImages); - testParseExample(ContentExample.mathBlockKatexSizing); - testParseExample(ContentExample.mathBlockKatexNestedSizing); - testParseExample(ContentExample.mathBlockKatexDelimSizing); - testParseExample(ContentExample.mathBlockKatexSpace); - testParseExample(ContentExample.mathBlockKatexSuperscript); - testParseExample(ContentExample.mathBlockKatexSubscript); - testParseExample(ContentExample.mathBlockKatexSubSuperScript); - testParseExample(ContentExample.mathBlockKatexRaisebox); - testParseExample(ContentExample.mathBlockKatexNegativeMargin); - testParseExample(ContentExample.mathBlockKatexLogo); - testParseExample(ContentExample.mathBlockKatexNegativeMarginsOnVlistRow); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart new file mode 100644 index 0000000000..be2ba56d11 --- /dev/null +++ b/test/model/katex_test.dart @@ -0,0 +1,698 @@ +import 'dart:io'; + +import 'package:zulip/model/settings.dart'; +import 'package:checks/checks.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test_api/scaffolding.dart'; +import 'package:zulip/model/content.dart'; +import 'package:zulip/model/katex.dart'; + +import 'binding.dart'; +import 'content_test.dart'; + +/// Holds examples of KaTeX Zulip content for test cases. +/// +/// For guidance on writing examples, see comments on [ContentExample]. +abstract class KatexExample { + // 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 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```', + '

' + '' + '1234567890' + '\\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' + '

', + [ + 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: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 + text: '1', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 + text: '2', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 + text: '3', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 + text: '4', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 + text: '5', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 + text: '6', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 + text: '7', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 + text: '8', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: '9', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 + text: '0', + nodes: null), + ]), + ]), + ]); + + static const mathBlockKatexNestedSizing = ContentExample( + 'math block; KaTeX nested sizing', + '```math\n\\tiny {1 \\Huge 2}\n```', + '

' + '' + '12' + '\\tiny {1 \\Huge 2}' + '

', + [ + MathBlockNode( + texSource: '\\tiny {1 \\Huge 2}', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: '1', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 + text: '2', + nodes: null), + ]), + ]), + ]), + ]); + + 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```', + '

' + '' + '([' + '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊' + '

', + [ + MathBlockNode( + texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), + KatexSpanNode( + styles: KatexSpanStyles(), + text: '⟨', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), + text: '(', + nodes: null), + ]), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), + text: '[', + nodes: null), + ]), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), + text: '⌈', + nodes: null), + ]), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), + text: '⌊', + nodes: null), + ]), + ]), + ]), + ]); + + static const mathBlockKatexSpace = ContentExample( + 'math block; KaTeX space', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2214883 + '```math\n1:2\n```', + '

' + '' + '1:21:2' + '

', [ + MathBlockNode( + texSource: '1:2', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode( + heightEm: 0.6444, + verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(), + text: '1', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + text: null, + nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(), + text: ':', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + text: null, + nodes: []), + ]), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode( + heightEm: 0.6444, + verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(), + text: '2', + nodes: null), + ]), + ]), + ]); + + static const mathBlockKatexSuperscript = ContentExample( + 'math block, KaTeX superscript; single vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 + '```math\na\'\n```', + '

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

', [ + MathBlockNode(texSource: 'a\'', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: '′', nodes: null), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexSubscript = ContentExample( + 'math block, KaTeX subscript; two vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 + '```math\nx_n\n```', + '

' + '' + 'xn' + 'x_n' + '

', [ + MathBlockNode(texSource: 'x_n', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'x', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n', nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexSubSuperScript = ContentExample( + 'math block, KaTeX subsup script; two vlist-r, multiple vertical offset rows', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 + '```math\n_u^o\n```', + '

' + '' + 'uo' + '_u^o' + '

', [ + MathBlockNode(texSource: "_u^o", nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.453 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'u', nodes: null), + ]), + ])), + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'o', nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexRaisebox = ContentExample( + 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 + '```math\na\\raisebox{0.25em}{\$b\$}c\n```', + '

' + '' + 'abc' + 'a\\raisebox{0.25em}{\$b\$}c' + '

', [ + MathBlockNode(texSource: 'a\\raisebox{0.25em}{\$b\$}c', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a', nodes: null), + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.25 + 3, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'b', nodes: null), + ]), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'c', nodes: null), + ]), + ]), + ]); + + static const mathBlockKatexNegativeMargin = ContentExample( + 'math block, KaTeX negative margin', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 + '```math\n1 \\! 2\n```', + '

' + '' + '1 ⁣21 \\! 2' + '

', [ + MathBlockNode(texSource: '1 \\! 2', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), text: '1', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: '2', nodes: null), + ]), + ]), + ]), + ]); + + static const mathBlockKatexLogo = ContentExample( + 'math block, KaTeX logo', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 + '```math\n\\KaTeX\n```', + '

' + '' + 'KaTeX' + '\\KaTeX' + '

', [ + MathBlockNode(texSource: '\\KaTeX', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'K', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 + text: 'A', nodes: null), + ]), + ])), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'T', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'E', nodes: null), + ]), + ])), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'X', nodes: null), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexNegativeMarginsOnVlistRow = ContentExample( + 'math block, KaTeX negative margins on a vlist row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 + '```math\nX_n\n```', + '

' + '' + 'XnX_n' + '

', [ + MathBlockNode(texSource: 'X_n', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + marginRightEm: 0.07847, + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'X', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n', nodes: null), + ]), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); +} + +void main() async { + TestZulipBinding.ensureInitialized(); + + await testBinding.globalStore.settings.setBool( + BoolGlobalSetting.renderKatex, true); + + testParseExample(KatexExample.mathBlockKatexSizing); + testParseExample(KatexExample.mathBlockKatexNestedSizing); + testParseExample(KatexExample.mathBlockKatexDelimSizing); + testParseExample(KatexExample.mathBlockKatexSpace); + testParseExample(KatexExample.mathBlockKatexSuperscript); + testParseExample(KatexExample.mathBlockKatexSubscript); + testParseExample(KatexExample.mathBlockKatexSubSuperScript); + testParseExample(KatexExample.mathBlockKatexRaisebox); + testParseExample(KatexExample.mathBlockKatexNegativeMargin); + testParseExample(KatexExample.mathBlockKatexLogo); + testParseExample(KatexExample.mathBlockKatexNegativeMarginsOnVlistRow); + + test('all KaTeX content examples are tested', () { + // Check that every ContentExample defined above has a corresponding + // actual test case that runs on it. If you've added a new example + // and this test breaks, remember to add a `testParseExample` call for it. + + // This implementation is a bit of a hack; it'd be cleaner to get the + // actual Dart parse tree using package:analyzer. Unfortunately that + // approach takes several seconds just to load the parser library, enough + // to add noticeably to the runtime of our whole test suite. + final thisFilename = Trace.current().frames[0].uri.path; + final source = File(thisFilename).readAsStringSync(); + final declaredExamples = RegExp(multiLine: true, + 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*KatexExample\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 + // any one platform is enough; avoid dealing with Windows file paths + ); +} diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 46ed10079e..86b6e4cc1c 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -23,6 +23,7 @@ import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/content_test.dart'; +import '../model/katex_test.dart'; import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; @@ -579,7 +580,7 @@ void main() { group('characters render at specific offsets with specific size', () { const testCases = <(ContentExample, List<(String, Offset, Size)>, {bool? skip})>[ - (ContentExample.mathBlockKatexSizing, skip: false, [ + (KatexExample.mathBlockKatexSizing, skip: false, [ ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), ('3', Offset(46.91, 16.55), Size(17.77, 43.00)), @@ -591,50 +592,50 @@ void main() { ('9', Offset(119.58, 35.91), Size(7.20, 17.00)), ('0', Offset(126.77, 39.68), Size(5.14, 12.00)), ]), - (ContentExample.mathBlockKatexNestedSizing, skip: false, [ + (KatexExample.mathBlockKatexNestedSizing, skip: false, [ ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), ]), - (ContentExample.mathBlockKatexDelimSizing, skip: false, [ + (KatexExample.mathBlockKatexDelimSizing, skip: false, [ ('(', Offset(8.00, 20.14), Size(9.42, 25.00)), ('[', Offset(17.42, 20.14), Size(9.71, 25.00)), ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), ]), - (ContentExample.mathBlockKatexSpace, skip: false, [ + (KatexExample.mathBlockKatexSpace, skip: false, [ ('1', Offset(0.00, 2.24), Size(10.28, 25.00)), (':', Offset(16.00, 2.24), Size(5.72, 25.00)), ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), ]), - (ContentExample.mathBlockKatexSuperscript, skip: false, [ + (KatexExample.mathBlockKatexSuperscript, skip: false, [ ('a', Offset(0.00, 5.28), Size(10.88, 25.00)), ('′', Offset(10.88, 1.13), Size(3.96, 17.00)), ]), - (ContentExample.mathBlockKatexSubscript, skip: false, [ + (KatexExample.mathBlockKatexSubscript, skip: false, [ ('x', Offset(0.00, 5.28), Size(11.76, 25.00)), ('n', Offset(11.76, 13.65), Size(8.63, 17.00)), ]), - (ContentExample.mathBlockKatexSubSuperScript, skip: false, [ + (KatexExample.mathBlockKatexSubSuperScript, skip: false, [ ('u', Offset(0.00, 15.65), Size(8.23, 17.00)), ('o', Offset(0.00, 2.07), Size(6.98, 17.00)), ]), - (ContentExample.mathBlockKatexRaisebox, skip: false, [ + (KatexExample.mathBlockKatexRaisebox, skip: false, [ ('a', Offset(0.00, 4.16), Size(10.88, 25.00)), ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), ]), - (ContentExample.mathBlockKatexNegativeMargin, skip: false, [ + (KatexExample.mathBlockKatexNegativeMargin, skip: false, [ ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), ]), - (ContentExample.mathBlockKatexLogo, skip: false, [ + (KatexExample.mathBlockKatexLogo, skip: false, [ ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), ]), - (ContentExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ + (KatexExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), ]), From 993bdc23b2e5945e9751f9c11150867f5bcc97f9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 14:09:57 -0700 Subject: [PATCH 339/423] katex test [nfc]: Make the KaTeX examples instances of KatexExample --- test/model/katex_test.dart | 33 ++++++++++++++++++--------------- test/widgets/content_test.dart | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index be2ba56d11..11d43400b3 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -10,14 +10,17 @@ import 'package:zulip/model/katex.dart'; import 'binding.dart'; import 'content_test.dart'; -/// Holds examples of KaTeX Zulip content for test cases. +/// An example of KaTeX Zulip content for test cases. /// /// For guidance on writing examples, see comments on [ContentExample]. -abstract class KatexExample { +class KatexExample extends ContentExample { + const KatexExample(super.description, super.markdown, super.html, + super.expectedNodes, {super.expectedText}); + // 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 const mathBlockKatexSizing = ContentExample( + static const mathBlockKatexSizing = KatexExample( '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```', @@ -91,7 +94,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexNestedSizing = ContentExample( + static const mathBlockKatexNestedSizing = KatexExample( 'math block; KaTeX nested sizing', '```math\n\\tiny {1 \\Huge 2}\n```', '

' @@ -130,7 +133,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexDelimSizing = ContentExample( + static const mathBlockKatexDelimSizing = KatexExample( '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```', @@ -199,7 +202,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexSpace = ContentExample( + static const mathBlockKatexSpace = KatexExample( 'math block; KaTeX space', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2214883 '```math\n1:2\n```', @@ -258,7 +261,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexSuperscript = ContentExample( + static const mathBlockKatexSuperscript = KatexExample( 'math block, KaTeX superscript; single vlist-r, single vertical offset row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 '```math\na\'\n```', @@ -310,7 +313,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexSubscript = ContentExample( + static const mathBlockKatexSubscript = KatexExample( 'math block, KaTeX subscript; two vlist-r, single vertical offset row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 '```math\nx_n\n```', @@ -366,7 +369,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexSubSuperScript = ContentExample( + static const mathBlockKatexSubSuperScript = KatexExample( 'math block, KaTeX subsup script; two vlist-r, multiple vertical offset rows', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 '```math\n_u^o\n```', @@ -436,7 +439,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexRaisebox = ContentExample( + static const mathBlockKatexRaisebox = KatexExample( 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 '```math\na\\raisebox{0.25em}{\$b\$}c\n```', @@ -480,7 +483,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexNegativeMargin = ContentExample( + static const mathBlockKatexNegativeMargin = KatexExample( 'math block, KaTeX negative margin', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 '```math\n1 \\! 2\n```', @@ -505,7 +508,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexLogo = ContentExample( + static const mathBlockKatexLogo = KatexExample( 'math block, KaTeX logo', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 '```math\n\\KaTeX\n```', @@ -595,7 +598,7 @@ abstract class KatexExample { ]), ]); - static const mathBlockKatexNegativeMarginsOnVlistRow = ContentExample( + static const mathBlockKatexNegativeMarginsOnVlistRow = KatexExample( 'math block, KaTeX negative margins on a vlist row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 '```math\nX_n\n```', @@ -675,7 +678,7 @@ void main() async { testParseExample(KatexExample.mathBlockKatexNegativeMarginsOnVlistRow); test('all KaTeX content examples are tested', () { - // Check that every ContentExample defined above has a corresponding + // Check that every KatexExample defined above has a corresponding // actual test case that runs on it. If you've added a new example // and this test breaks, remember to add a `testParseExample` call for it. @@ -686,7 +689,7 @@ void main() async { final thisFilename = Trace.current().frames[0].uri.path; final source = File(thisFilename).readAsStringSync(); final declaredExamples = RegExp(multiLine: true, - r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*ContentExample\s*(?:\.\s*inline\s*)?\(', + r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*KatexExample\s*(?:\.\s*inline\s*)?\(', ).allMatches(source).map((m) => m.group(1)); final testedExamples = RegExp(multiLine: true, r'^\s*testParseExample\s*\(\s*KatexExample\s*\.\s*(\w+)(?:,\s*skip:\s*true)?\s*\);', diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 86b6e4cc1c..abdcb9d25f 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -579,7 +579,7 @@ void main() { }); group('characters render at specific offsets with specific size', () { - const testCases = <(ContentExample, List<(String, Offset, Size)>, {bool? skip})>[ + const testCases = <(KatexExample, List<(String, Offset, Size)>, {bool? skip})>[ (KatexExample.mathBlockKatexSizing, skip: false, [ ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), From 6f66b06de42233ffe9584fa2b132b5cdc5ef6fa7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 13:52:10 -0700 Subject: [PATCH 340/423] content test [nfc]: Move some helpers to top level, so importable This will let us split some of this file's tests to a different file and have them still use these helpers. --- test/widgets/content_test.dart | 68 +++++++++++++++++----------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index abdcb9d25f..cae7414083 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -108,6 +108,40 @@ TextStyle? mergedStyleOf(WidgetTester tester, Pattern spanPattern, { /// and reports the target's font size. typedef TargetFontSizeFinder = double Function(InlineSpan rootSpan); +Widget plainContent(String html) { + return Builder(builder: (context) => + DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: parseContent(html).nodes))); +} + +// TODO(#488) For content that we need to show outside a per-message context +// or a context without a full PerAccountStore, make sure to include tests +// that don't provide such context. +Future prepareContent(WidgetTester tester, Widget child, { + List navObservers = const [], + bool wrapWithPerAccountStoreWidget = false, +}) async { + if (wrapWithPerAccountStoreWidget) { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + } + + addTearDown(testBinding.reset); + + prepareBoringImageHttpClient(); + + await tester.pumpWidget(TestZulipApp( + accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, + navigatorObservers: navObservers, + child: child)); + await tester.pump(); // global store + if (wrapWithPerAccountStoreWidget) { + await tester.pump(); + } + + debugNetworkImageHttpClientProvider = null; +} + void main() { // For testing a new content feature: // @@ -122,45 +156,11 @@ void main() { TestZulipBinding.ensureInitialized(); - Widget plainContent(String html) { - return Builder(builder: (context) => - DefaultTextStyle( - style: ContentTheme.of(context).textStylePlainParagraph, - child: BlockContentList(nodes: parseContent(html).nodes))); - } - Widget messageContent(String html) { return MessageContent(message: eg.streamMessage(content: html), content: parseContent(html)); } - // TODO(#488) For content that we need to show outside a per-message context - // or a context without a full PerAccountStore, make sure to include tests - // that don't provide such context. - Future prepareContent(WidgetTester tester, Widget child, { - List navObservers = const [], - bool wrapWithPerAccountStoreWidget = false, - }) async { - if (wrapWithPerAccountStoreWidget) { - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - } - - addTearDown(testBinding.reset); - - prepareBoringImageHttpClient(); - - await tester.pumpWidget(TestZulipApp( - accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, - navigatorObservers: navObservers, - child: child)); - await tester.pump(); // global store - if (wrapWithPerAccountStoreWidget) { - await tester.pump(); - } - - debugNetworkImageHttpClientProvider = null; - } - /// Test that the given content example renders without throwing an exception. /// /// This requires [ContentExample.expectedText] to be non-null in order to From c5a10af328a2a72742448ccec9d6d665b09ea1e7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 14:20:28 -0700 Subject: [PATCH 341/423] katex [nfc]: Move KaTeX widgets to their own file This is a pretty self-contained swath of our content widgets -- the only interface the others use from these is KatexWidget. So it works well to split into a separate file, paralleling the model files. --- lib/widgets/content.dart | 227 -------------------------------- lib/widgets/katex.dart | 229 +++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 147 ++------------------- test/widgets/katex_test.dart | 153 ++++++++++++++++++++++ 4 files changed, 391 insertions(+), 365 deletions(-) create mode 100644 test/widgets/katex_test.dart diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 2263b74f8b..551956966e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -16,7 +15,6 @@ import '../model/binding.dart'; import '../model/content.dart'; import '../model/emoji.dart'; import '../model/internal_link.dart'; -import '../model/katex.dart'; import '../model/presence.dart'; import 'actions.dart'; import 'code_block.dart'; @@ -834,231 +832,6 @@ class MathBlock extends StatelessWidget { } } -/// Creates a base text style for rendering KaTeX content. -/// -/// This applies the CSS styles defined in .katex class in katex.scss : -/// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 -/// -/// Requires the [style.fontSize] to be non-null. -TextStyle mkBaseKatexTextStyle(TextStyle style) { - return style.copyWith( - fontSize: style.fontSize! * 1.21, - fontFamily: 'KaTeX_Main', - height: 1.2, - fontWeight: FontWeight.normal, - fontStyle: FontStyle.normal, - textBaseline: TextBaseline.alphabetic, - leadingDistribution: TextLeadingDistribution.even, - decoration: TextDecoration.none, - fontFamilyFallback: const []); -} - -@visibleForTesting -class KatexWidget extends StatelessWidget { - const KatexWidget({ - super.key, - required this.textStyle, - required this.nodes, - }); - - final TextStyle textStyle; - final List nodes; - - @override - Widget build(BuildContext context) { - Widget widget = _KatexNodeList(nodes: nodes); - - return Directionality( - textDirection: TextDirection.ltr, - child: DefaultTextStyle( - style: mkBaseKatexTextStyle(textStyle).copyWith( - color: ContentTheme.of(context).textStylePlainParagraph.color), - child: widget)); - } -} - -class _KatexNodeList extends StatelessWidget { - const _KatexNodeList({required this.nodes}); - - final List nodes; - - @override - Widget build(BuildContext context) { - return Text.rich(TextSpan( - children: List.unmodifiable(nodes.map((e) { - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - // Work around a bug where text inside a WidgetSpan could be scaled - // multiple times incorrectly, if the system font scale is larger - // than 1x. - // See: https://github.com/flutter/flutter/issues/126962 - child: MediaQuery( - data: MediaQueryData(textScaler: TextScaler.noScaling), - child: switch (e) { - KatexSpanNode() => _KatexSpan(e), - KatexStrutNode() => _KatexStrut(e), - KatexVlistNode() => _KatexVlist(e), - KatexNegativeMarginNode() => _KatexNegativeMargin(e), - })); - })))); - } -} - -class _KatexSpan extends StatelessWidget { - const _KatexSpan(this.node); - - final KatexSpanNode node; - - @override - Widget build(BuildContext context) { - var em = DefaultTextStyle.of(context).style.fontSize!; - - Widget widget = const SizedBox.shrink(); - if (node.text != null) { - widget = Text(node.text!); - } else if (node.nodes != null && node.nodes!.isNotEmpty) { - widget = _KatexNodeList(nodes: node.nodes!); - } - - final styles = node.styles; - - // Currently, we expect `top` to be only present with the - // vlist inner row span, and parser handles that explicitly. - assert(styles.topEm == null); - - final fontFamily = styles.fontFamily; - final fontSize = switch (styles.fontSizeEm) { - double fontSizeEm => fontSizeEm * em, - null => null, - }; - if (fontSize != null) em = fontSize; - - final fontWeight = switch (styles.fontWeight) { - KatexSpanFontWeight.bold => FontWeight.bold, - null => null, - }; - var fontStyle = switch (styles.fontStyle) { - KatexSpanFontStyle.normal => FontStyle.normal, - KatexSpanFontStyle.italic => FontStyle.italic, - null => null, - }; - - TextStyle? textStyle; - if (fontFamily != null || - fontSize != null || - fontWeight != null || - fontStyle != null) { - // TODO(upstream) remove this workaround when upstream fixes the broken - // rendering of KaTeX_Math font with italic font style on Android: - // https://github.com/flutter/flutter/issues/167474 - if (defaultTargetPlatform == TargetPlatform.android && - fontFamily == 'KaTeX_Math') { - fontStyle = FontStyle.normal; - } - - textStyle = TextStyle( - fontFamily: fontFamily, - fontSize: fontSize, - fontWeight: fontWeight, - fontStyle: fontStyle, - ); - } - final textAlign = switch (styles.textAlign) { - KatexSpanTextAlign.left => TextAlign.left, - KatexSpanTextAlign.center => TextAlign.center, - KatexSpanTextAlign.right => TextAlign.right, - null => null, - }; - - if (textStyle != null || textAlign != null) { - widget = DefaultTextStyle.merge( - style: textStyle, - textAlign: textAlign, - child: widget); - } - - widget = SizedBox( - height: styles.heightEm != null - ? styles.heightEm! * em - : null, - child: widget); - - final margin = switch ((styles.marginLeftEm, styles.marginRightEm)) { - (null, null) => null, - (null, final marginRightEm?) => - EdgeInsets.only(right: marginRightEm * em), - (final marginLeftEm?, null) => - EdgeInsets.only(left: marginLeftEm * em), - (final marginLeftEm?, final marginRightEm?) => - EdgeInsets.only(left: marginLeftEm * em, right: marginRightEm * em), - }; - - if (margin != null) { - assert(margin.isNonNegative); - widget = Padding(padding: margin, child: widget); - } - - return widget; - } -} - -class _KatexStrut extends StatelessWidget { - const _KatexStrut(this.node); - - final KatexStrutNode node; - - @override - Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; - - final verticalAlignEm = node.verticalAlignEm; - if (verticalAlignEm == null) { - return SizedBox(height: node.heightEm * em); - } - - return SizedBox( - height: node.heightEm * em, - child: Baseline( - baseline: (verticalAlignEm + node.heightEm) * em, - baselineType: TextBaseline.alphabetic, - child: const Text('')), - ); - } -} - -class _KatexVlist extends StatelessWidget { - const _KatexVlist(this.node); - - final KatexVlistNode node; - - @override - Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; - - return Stack(children: List.unmodifiable(node.rows.map((row) { - return Transform.translate( - offset: Offset(0, row.verticalOffsetEm * em), - child: _KatexSpan(row.node)); - }))); - } -} - -class _KatexNegativeMargin extends StatelessWidget { - const _KatexNegativeMargin(this.node); - - final KatexNegativeMarginNode node; - - @override - Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; - - return NegativeLeftOffset( - leftOffset: node.leftOffsetEm * em, - child: _KatexNodeList(nodes: node.nodes)); - } -} - class WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart index 9b89270c8b..9d439ffdd3 100644 --- a/lib/widgets/katex.dart +++ b/lib/widgets/katex.dart @@ -4,6 +4,235 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; +import '../model/content.dart'; +import '../model/katex.dart'; +import 'content.dart'; + +/// Creates a base text style for rendering KaTeX content. +/// +/// This applies the CSS styles defined in .katex class in katex.scss : +/// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 +/// +/// Requires the [style.fontSize] to be non-null. +TextStyle mkBaseKatexTextStyle(TextStyle style) { + return style.copyWith( + fontSize: style.fontSize! * 1.21, + fontFamily: 'KaTeX_Main', + height: 1.2, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + decoration: TextDecoration.none, + fontFamilyFallback: const []); +} + +@visibleForTesting +class KatexWidget extends StatelessWidget { + const KatexWidget({ + super.key, + required this.textStyle, + required this.nodes, + }); + + final TextStyle textStyle; + final List nodes; + + @override + Widget build(BuildContext context) { + Widget widget = _KatexNodeList(nodes: nodes); + + return Directionality( + textDirection: TextDirection.ltr, + child: DefaultTextStyle( + style: mkBaseKatexTextStyle(textStyle).copyWith( + color: ContentTheme.of(context).textStylePlainParagraph.color), + child: widget)); + } +} + +class _KatexNodeList extends StatelessWidget { + const _KatexNodeList({required this.nodes}); + + final List nodes; + + @override + Widget build(BuildContext context) { + return Text.rich(TextSpan( + children: List.unmodifiable(nodes.map((e) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + // Work around a bug where text inside a WidgetSpan could be scaled + // multiple times incorrectly, if the system font scale is larger + // than 1x. + // See: https://github.com/flutter/flutter/issues/126962 + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.noScaling), + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), + })); + })))); + } +} + +class _KatexSpan extends StatelessWidget { + const _KatexSpan(this.node); + + final KatexSpanNode node; + + @override + Widget build(BuildContext context) { + var em = DefaultTextStyle.of(context).style.fontSize!; + + Widget widget = const SizedBox.shrink(); + if (node.text != null) { + widget = Text(node.text!); + } else if (node.nodes != null && node.nodes!.isNotEmpty) { + widget = _KatexNodeList(nodes: node.nodes!); + } + + final styles = node.styles; + + // Currently, we expect `top` to be only present with the + // vlist inner row span, and parser handles that explicitly. + assert(styles.topEm == null); + + final fontFamily = styles.fontFamily; + final fontSize = switch (styles.fontSizeEm) { + double fontSizeEm => fontSizeEm * em, + null => null, + }; + if (fontSize != null) em = fontSize; + + final fontWeight = switch (styles.fontWeight) { + KatexSpanFontWeight.bold => FontWeight.bold, + null => null, + }; + var fontStyle = switch (styles.fontStyle) { + KatexSpanFontStyle.normal => FontStyle.normal, + KatexSpanFontStyle.italic => FontStyle.italic, + null => null, + }; + + TextStyle? textStyle; + if (fontFamily != null || + fontSize != null || + fontWeight != null || + fontStyle != null) { + // TODO(upstream) remove this workaround when upstream fixes the broken + // rendering of KaTeX_Math font with italic font style on Android: + // https://github.com/flutter/flutter/issues/167474 + if (defaultTargetPlatform == TargetPlatform.android && + fontFamily == 'KaTeX_Math') { + fontStyle = FontStyle.normal; + } + + textStyle = TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + fontStyle: fontStyle, + ); + } + final textAlign = switch (styles.textAlign) { + KatexSpanTextAlign.left => TextAlign.left, + KatexSpanTextAlign.center => TextAlign.center, + KatexSpanTextAlign.right => TextAlign.right, + null => null, + }; + + if (textStyle != null || textAlign != null) { + widget = DefaultTextStyle.merge( + style: textStyle, + textAlign: textAlign, + child: widget); + } + + widget = SizedBox( + height: styles.heightEm != null + ? styles.heightEm! * em + : null, + child: widget); + + final margin = switch ((styles.marginLeftEm, styles.marginRightEm)) { + (null, null) => null, + (null, final marginRightEm?) => + EdgeInsets.only(right: marginRightEm * em), + (final marginLeftEm?, null) => + EdgeInsets.only(left: marginLeftEm * em), + (final marginLeftEm?, final marginRightEm?) => + EdgeInsets.only(left: marginLeftEm * em, right: marginRightEm * em), + }; + + if (margin != null) { + assert(margin.isNonNegative); + widget = Padding(padding: margin, child: widget); + } + + return widget; + } +} + +class _KatexStrut extends StatelessWidget { + const _KatexStrut(this.node); + + final KatexStrutNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + final verticalAlignEm = node.verticalAlignEm; + if (verticalAlignEm == null) { + return SizedBox(height: node.heightEm * em); + } + + return SizedBox( + height: node.heightEm * em, + child: Baseline( + baseline: (verticalAlignEm + node.heightEm) * em, + baselineType: TextBaseline.alphabetic, + child: const Text('')), + ); + } +} + +class _KatexVlist extends StatelessWidget { + const _KatexVlist(this.node); + + final KatexVlistNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return Stack(children: List.unmodifiable(node.rows.map((row) { + return Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: _KatexSpan(row.node)); + }))); + } +} + +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return NegativeLeftOffset( + leftOffset: node.leftOffsetEm * em, + child: _KatexNodeList(nodes: node.nodes)); + } +} + class NegativeLeftOffset extends SingleChildRenderObjectWidget { NegativeLeftOffset({super.key, required this.leftOffset, super.child}) : assert(leftOffset.isNegative), diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index cae7414083..e615dc7b81 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -3,7 +3,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -14,6 +13,7 @@ import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/katex.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; @@ -23,7 +23,6 @@ import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/content_test.dart'; -import '../model/katex_test.dart'; import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; @@ -557,6 +556,10 @@ void main() { }); group('MathBlock', () { + // See also katex_test.dart for detailed tests of + // how we render the inside of a math block. + // These tests check how it relates to the enclosing Zulip message. + testContentSmoke(ContentExample.mathBlock); testWidgets('displays KaTeX source; experimental flag disabled', (tester) async { @@ -577,100 +580,6 @@ void main() { await prepareContent(tester, plainContent(ContentExample.mathBlock.html)); tester.widget(find.text('λ', findRichText: true)); }); - - group('characters render at specific offsets with specific size', () { - const testCases = <(KatexExample, List<(String, Offset, Size)>, {bool? skip})>[ - (KatexExample.mathBlockKatexSizing, skip: false, [ - ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), - ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), - ('3', Offset(46.91, 16.55), Size(17.77, 43.00)), - ('4', Offset(64.68, 21.98), Size(14.80, 36.00)), - ('5', Offset(79.48, 26.50), Size(12.34, 30.00)), - ('6', Offset(91.82, 30.26), Size(10.28, 25.00)), - ('7', Offset(102.10, 32.15), Size(9.25, 22.00)), - ('8', Offset(111.35, 34.03), Size(8.23, 20.00)), - ('9', Offset(119.58, 35.91), Size(7.20, 17.00)), - ('0', Offset(126.77, 39.68), Size(5.14, 12.00)), - ]), - (KatexExample.mathBlockKatexNestedSizing, skip: false, [ - ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), - ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), - ]), - (KatexExample.mathBlockKatexDelimSizing, skip: false, [ - ('(', Offset(8.00, 20.14), Size(9.42, 25.00)), - ('[', Offset(17.42, 20.14), Size(9.71, 25.00)), - ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), - ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), - ]), - (KatexExample.mathBlockKatexSpace, skip: false, [ - ('1', Offset(0.00, 2.24), Size(10.28, 25.00)), - (':', Offset(16.00, 2.24), Size(5.72, 25.00)), - ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), - ]), - (KatexExample.mathBlockKatexSuperscript, skip: false, [ - ('a', Offset(0.00, 5.28), Size(10.88, 25.00)), - ('′', Offset(10.88, 1.13), Size(3.96, 17.00)), - ]), - (KatexExample.mathBlockKatexSubscript, skip: false, [ - ('x', Offset(0.00, 5.28), Size(11.76, 25.00)), - ('n', Offset(11.76, 13.65), Size(8.63, 17.00)), - ]), - (KatexExample.mathBlockKatexSubSuperScript, skip: false, [ - ('u', Offset(0.00, 15.65), Size(8.23, 17.00)), - ('o', Offset(0.00, 2.07), Size(6.98, 17.00)), - ]), - (KatexExample.mathBlockKatexRaisebox, skip: false, [ - ('a', Offset(0.00, 4.16), Size(10.88, 25.00)), - ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), - ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), - ]), - (KatexExample.mathBlockKatexNegativeMargin, skip: false, [ - ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), - ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), - ]), - (KatexExample.mathBlockKatexLogo, skip: false, [ - ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), - ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), - ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), - ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), - ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), - ]), - (KatexExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ - ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), - ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), - ]), - ]; - - for (final testCase in testCases) { - testWidgets(testCase.$1.description, (tester) async { - await _loadKatexFonts(); - - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - await prepareContent(tester, plainContent(testCase.$1.html)); - - final baseRect = tester.getRect(find.byType(KatexWidget)); - - for (final characterData in testCase.$2) { - final character = characterData.$1; - final expectedTopLeftOffset = characterData.$2; - final expectedSize = characterData.$3; - - final rect = tester.getRect(find.text(character)); - final topLeftOffset = rect.topLeft - baseRect.topLeft; - final size = rect.size; - - check(topLeftOffset) - .within(distance: 0.05, from: expectedTopLeftOffset); - check(size) - .within(distance: 0.05, from: expectedSize); - } - }, skip: testCase.skip); - } - }); }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], @@ -1078,6 +987,10 @@ void main() { }); group('inline math', () { + // See also katex_test.dart for detailed tests of + // how we render the inside of a math span. + // These tests check how it relates to the enclosing Zulip message. + testContentSmoke(ContentExample.mathInline); testWidgets('maintains font-size ratio with surrounding text', (tester) async { @@ -1486,45 +1399,3 @@ void main() { }); }); } - -Future _loadKatexFonts() async { - const fonts = { - 'KaTeX_AMS': ['KaTeX_AMS-Regular.ttf'], - 'KaTeX_Caligraphic': [ - 'KaTeX_Caligraphic-Regular.ttf', - 'KaTeX_Caligraphic-Bold.ttf', - ], - 'KaTeX_Fraktur': [ - 'KaTeX_Fraktur-Regular.ttf', - 'KaTeX_Fraktur-Bold.ttf', - ], - 'KaTeX_Main': [ - 'KaTeX_Main-Regular.ttf', - 'KaTeX_Main-Bold.ttf', - 'KaTeX_Main-Italic.ttf', - 'KaTeX_Main-BoldItalic.ttf', - ], - 'KaTeX_Math': [ - 'KaTeX_Math-Italic.ttf', - 'KaTeX_Math-BoldItalic.ttf', - ], - 'KaTeX_SansSerif': [ - 'KaTeX_SansSerif-Regular.ttf', - 'KaTeX_SansSerif-Bold.ttf', - 'KaTeX_SansSerif-Italic.ttf', - ], - 'KaTeX_Script': ['KaTeX_Script-Regular.ttf'], - 'KaTeX_Size1': ['KaTeX_Size1-Regular.ttf'], - 'KaTeX_Size2': ['KaTeX_Size2-Regular.ttf'], - 'KaTeX_Size3': ['KaTeX_Size3-Regular.ttf'], - 'KaTeX_Size4': ['KaTeX_Size4-Regular.ttf'], - 'KaTeX_Typewriter': ['KaTeX_Typewriter-Regular.ttf'], - }; - for (final MapEntry(key: fontFamily, value: fontFiles) in fonts.entries) { - final fontLoader = FontLoader(fontFamily); - for (final fontFile in fontFiles) { - fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); - } - await fontLoader.load(); - } -} diff --git a/test/widgets/katex_test.dart b/test/widgets/katex_test.dart new file mode 100644 index 0000000000..eb1c776a30 --- /dev/null +++ b/test/widgets/katex_test.dart @@ -0,0 +1,153 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/katex.dart'; + +import '../model/binding.dart'; +import '../model/katex_test.dart'; +import '../model/store_checks.dart'; +import 'content_test.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('MathBlock', () { + group('characters render at specific offsets with specific size', () { + const testCases = <(KatexExample, List<(String, Offset, Size)>, {bool? skip})>[ + (KatexExample.mathBlockKatexSizing, skip: false, [ + ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), + ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), + ('3', Offset(46.91, 16.55), Size(17.77, 43.00)), + ('4', Offset(64.68, 21.98), Size(14.80, 36.00)), + ('5', Offset(79.48, 26.50), Size(12.34, 30.00)), + ('6', Offset(91.82, 30.26), Size(10.28, 25.00)), + ('7', Offset(102.10, 32.15), Size(9.25, 22.00)), + ('8', Offset(111.35, 34.03), Size(8.23, 20.00)), + ('9', Offset(119.58, 35.91), Size(7.20, 17.00)), + ('0', Offset(126.77, 39.68), Size(5.14, 12.00)), + ]), + (KatexExample.mathBlockKatexNestedSizing, skip: false, [ + ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), + ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), + ]), + (KatexExample.mathBlockKatexDelimSizing, skip: false, [ + ('(', Offset(8.00, 20.14), Size(9.42, 25.00)), + ('[', Offset(17.42, 20.14), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), + ]), + (KatexExample.mathBlockKatexSpace, skip: false, [ + ('1', Offset(0.00, 2.24), Size(10.28, 25.00)), + (':', Offset(16.00, 2.24), Size(5.72, 25.00)), + ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), + ]), + (KatexExample.mathBlockKatexSuperscript, skip: false, [ + ('a', Offset(0.00, 5.28), Size(10.88, 25.00)), + ('′', Offset(10.88, 1.13), Size(3.96, 17.00)), + ]), + (KatexExample.mathBlockKatexSubscript, skip: false, [ + ('x', Offset(0.00, 5.28), Size(11.76, 25.00)), + ('n', Offset(11.76, 13.65), Size(8.63, 17.00)), + ]), + (KatexExample.mathBlockKatexSubSuperScript, skip: false, [ + ('u', Offset(0.00, 15.65), Size(8.23, 17.00)), + ('o', Offset(0.00, 2.07), Size(6.98, 17.00)), + ]), + (KatexExample.mathBlockKatexRaisebox, skip: false, [ + ('a', Offset(0.00, 4.16), Size(10.88, 25.00)), + ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), + ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), + ]), + (KatexExample.mathBlockKatexNegativeMargin, skip: false, [ + ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), + ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), + ]), + (KatexExample.mathBlockKatexLogo, skip: false, [ + ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), + ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), + ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), + ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), + ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), + ]), + (KatexExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ + ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), + ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), + ]), + ]; + + for (final testCase in testCases) { + testWidgets(testCase.$1.description, (tester) async { + await _loadKatexFonts(); + + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + await prepareContent(tester, plainContent(testCase.$1.html)); + + final baseRect = tester.getRect(find.byType(KatexWidget)); + + for (final characterData in testCase.$2) { + final character = characterData.$1; + final expectedTopLeftOffset = characterData.$2; + final expectedSize = characterData.$3; + + final rect = tester.getRect(find.text(character)); + final topLeftOffset = rect.topLeft - baseRect.topLeft; + final size = rect.size; + + check(topLeftOffset) + .within(distance: 0.05, from: expectedTopLeftOffset); + check(size) + .within(distance: 0.05, from: expectedSize); + } + }, skip: testCase.skip); + } + }); + }); +} + +Future _loadKatexFonts() async { + const fonts = { + 'KaTeX_AMS': ['KaTeX_AMS-Regular.ttf'], + 'KaTeX_Caligraphic': [ + 'KaTeX_Caligraphic-Regular.ttf', + 'KaTeX_Caligraphic-Bold.ttf', + ], + 'KaTeX_Fraktur': [ + 'KaTeX_Fraktur-Regular.ttf', + 'KaTeX_Fraktur-Bold.ttf', + ], + 'KaTeX_Main': [ + 'KaTeX_Main-Regular.ttf', + 'KaTeX_Main-Bold.ttf', + 'KaTeX_Main-Italic.ttf', + 'KaTeX_Main-BoldItalic.ttf', + ], + 'KaTeX_Math': [ + 'KaTeX_Math-Italic.ttf', + 'KaTeX_Math-BoldItalic.ttf', + ], + 'KaTeX_SansSerif': [ + 'KaTeX_SansSerif-Regular.ttf', + 'KaTeX_SansSerif-Bold.ttf', + 'KaTeX_SansSerif-Italic.ttf', + ], + 'KaTeX_Script': ['KaTeX_Script-Regular.ttf'], + 'KaTeX_Size1': ['KaTeX_Size1-Regular.ttf'], + 'KaTeX_Size2': ['KaTeX_Size2-Regular.ttf'], + 'KaTeX_Size3': ['KaTeX_Size3-Regular.ttf'], + 'KaTeX_Size4': ['KaTeX_Size4-Regular.ttf'], + 'KaTeX_Typewriter': ['KaTeX_Typewriter-Regular.ttf'], + }; + for (final MapEntry(key: fontFamily, value: fontFiles) in fonts.entries) { + final fontLoader = FontLoader(fontFamily); + for (final fontFile in fontFiles) { + fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); + } + await fontLoader.load(); + } +} From 7fb722e808119a080e03da6979abecea860f25fd Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 14:51:51 -0700 Subject: [PATCH 342/423] katex test [nfc]: Make some examples more compact --- test/model/katex_test.dart | 316 +++++++++++++++---------------------- 1 file changed, 124 insertions(+), 192 deletions(-) diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 11d43400b3..6f4e175a72 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -40,57 +40,43 @@ class KatexExample extends ContentExample { '7' '8' '9' - '0

', - [ + '0

', [ 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: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 - text: '2', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 - text: '3', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 - text: '4', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 - text: '5', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 - text: '6', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 - text: '7', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 - text: '8', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: '9', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 - text: '0', - nodes: null), - ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 + text: '1', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 + text: '2', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 + text: '3', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 + text: '4', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 + text: '5', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 + text: '6', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 + text: '7', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 + text: '8', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: '9', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 + text: '0', nodes: null), + ]), ]), ]); @@ -106,31 +92,21 @@ class KatexExample extends ContentExample { '' '' '1' - '2

', - [ - MathBlockNode( - texSource: '\\tiny {1 \\Huge 2}', - nodes: [ + '2

', [ + MathBlockNode(texSource: '\\tiny {1 \\Huge 2}', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 + text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), + text: '1', nodes: null), KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 - text: '2', - nodes: null), - ]), + styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 + text: '2', nodes: null), ]), ]), + ]), ]); static const mathBlockKatexDelimSizing = KatexExample( @@ -148,58 +124,34 @@ class KatexExample extends ContentExample { '(' '[' '' - '

', - [ - MathBlockNode( - texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '⟨', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), - text: '(', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), - text: '[', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), - text: '⌈', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), - text: '⌊', - nodes: null), - ]), - ]), + '

', [ + MathBlockNode(texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), + KatexSpanNode(styles: KatexSpanStyles(), + text: '⟨', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), + text: '(', nodes: null), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), + text: '[', nodes: null), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), + text: '⌈', nodes: null), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), + text: '⌊', nodes: null), + ]), ]), + ]), ]); static const mathBlockKatexSpace = KatexExample( @@ -219,46 +171,26 @@ class KatexExample extends ContentExample { '' '' '2

', [ - MathBlockNode( - texSource: '1:2', - nodes: [ + MathBlockNode(texSource: '1:2', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), + text: '1', nodes: null), KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode( - heightEm: 0.6444, - verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, - nodes: []), - KatexSpanNode( - styles: KatexSpanStyles(), - text: ':', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, - nodes: []), - ]), + styles: KatexSpanStyles(marginRightEm: 0.2778), + text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(), + text: ':', nodes: null), KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode( - heightEm: 0.6444, - verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '2', - nodes: null), - ]), + styles: KatexSpanStyles(marginRightEm: 0.2778), + text: null, nodes: []), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), + text: '2', nodes: null), ]), + ]), ]); static const mathBlockKatexSuperscript = KatexExample( @@ -553,46 +485,46 @@ class KatexExample extends ContentExample { text: 'K', nodes: null), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.905 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 - text: 'A', nodes: null), - ]), - ])), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'T', nodes: null), + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 + text: 'A', nodes: null), + ]), + ])), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'T', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'E', nodes: null), + ]), + ])), + ]), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.7845 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'E', nodes: null), - ]), - ])), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'X', nodes: null), - ]), + KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'X', nodes: null), ]), ]), ]), ]), + ]), ]), ]), ]), From 15c68e0815589fbb60a7ef91c2750b784088db19 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 14:59:51 -0700 Subject: [PATCH 343/423] katex test [nfc]: Simplify a bit with a KatexExample.block constructor This removes some repetitive noisy bits from these examples, as well as a level of indentation. --- test/model/katex_test.dart | 586 +++++++++++++++++------------------ test/widgets/katex_test.dart | 2 +- 2 files changed, 283 insertions(+), 305 deletions(-) diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 6f4e175a72..581719fdba 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -14,16 +14,18 @@ import 'content_test.dart'; /// /// For guidance on writing examples, see comments on [ContentExample]. class KatexExample extends ContentExample { - const KatexExample(super.description, super.markdown, super.html, - super.expectedNodes, {super.expectedText}); + KatexExample.block(String description, String texSource, String html, + List? expectedNodes) + : super(description, '```math\n$texSource\n```', html, + [MathBlockNode(texSource: texSource, nodes: expectedNodes)]); // 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 const mathBlockKatexSizing = KatexExample( + static final mathBlockKatexSizing = KatexExample.block( '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```', + '\\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', '

' '' '1234567890' @@ -41,48 +43,44 @@ class KatexExample extends ContentExample { '8' '9' '0

', [ - 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: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 - text: '1', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 - text: '2', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 - text: '3', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 - text: '4', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 - text: '5', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 - text: '6', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 - text: '7', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 - text: '8', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: '9', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 - text: '0', nodes: null), - ]), - ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 + text: '1', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 + text: '2', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 + text: '3', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 + text: '4', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 + text: '5', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 + text: '6', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 + text: '7', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 + text: '8', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: '9', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 + text: '0', nodes: null), + ]), ]); - static const mathBlockKatexNestedSizing = KatexExample( + static final mathBlockKatexNestedSizing = KatexExample.block( 'math block; KaTeX nested sizing', - '```math\n\\tiny {1 \\Huge 2}\n```', + '\\tiny {1 \\Huge 2}', '

' '' '12' @@ -93,26 +91,24 @@ class KatexExample extends ContentExample { '' '1' '2

', [ - MathBlockNode(texSource: '\\tiny {1 \\Huge 2}', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 - text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), - text: '1', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 - text: '2', nodes: null), - ]), - ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 + text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), + text: '1', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 + text: '2', nodes: null), + ]), ]), ]); - static const mathBlockKatexDelimSizing = KatexExample( + static final mathBlockKatexDelimSizing = KatexExample.block( '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```', + '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', '

' '' '([' @@ -125,39 +121,37 @@ class KatexExample extends ContentExample { '[' '' '

', [ - MathBlockNode(texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), + KatexSpanNode(styles: KatexSpanStyles(), + text: '⟨', nodes: null), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), - KatexSpanNode(styles: KatexSpanStyles(), - text: '⟨', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), - text: '(', nodes: null), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), - text: '[', nodes: null), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), - text: '⌈', nodes: null), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), - text: '⌊', nodes: null), - ]), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), + text: '(', nodes: null), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), + text: '[', nodes: null), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), + text: '⌈', nodes: null), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), + text: '⌊', nodes: null), ]), ]), ]); - static const mathBlockKatexSpace = KatexExample( + static final mathBlockKatexSpace = KatexExample.block( 'math block; KaTeX space', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2214883 - '```math\n1:2\n```', + '1:2', '

' '' '1:21:2' @@ -171,32 +165,30 @@ class KatexExample extends ContentExample { '' '' '2

', [ - MathBlockNode(texSource: '1:2', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), - text: '1', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, nodes: []), - KatexSpanNode(styles: KatexSpanStyles(), - text: ':', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, nodes: []), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), - text: '2', nodes: null), - ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), + text: '1', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(), + text: ':', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + text: null, nodes: []), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), + text: '2', nodes: null), ]), ]); - static const mathBlockKatexSuperscript = KatexExample( + static final mathBlockKatexSuperscript = KatexExample.block( 'math block, KaTeX superscript; single vlist-r, single vertical offset row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 - '```math\na\'\n```', + 'a\'', '

' '' 'a' @@ -215,40 +207,38 @@ class KatexExample extends ContentExample { '' '' '

', [ - MathBlockNode(texSource: 'a\'', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -3.113 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '′', nodes: null), - ]), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: '′', nodes: null), ]), - ])), - ]), + ]), + ])), ]), - ]), + ]), ]), ]), ]); - static const mathBlockKatexSubscript = KatexExample( + static final mathBlockKatexSubscript = KatexExample.block( 'math block, KaTeX subscript; two vlist-r, single vertical offset row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 - '```math\nx_n\n```', + 'x_n', '

' '' 'xn' @@ -269,42 +259,40 @@ class KatexExample extends ContentExample { '' '' '

', [ - MathBlockNode(texSource: 'x_n', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'x', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.55 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'n', nodes: null), - ]), - ])), - ]), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'x', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n', nodes: null), + ]), + ])), ]), - ]), + ]), ]), ]), ]); - static const mathBlockKatexSubSuperScript = KatexExample( + static final mathBlockKatexSubSuperScript = KatexExample.block( 'math block, KaTeX subsup script; two vlist-r, multiple vertical offset rows', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 - '```math\n_u^o\n```', + '_u^o', '

' '' 'uo' @@ -329,52 +317,50 @@ class KatexExample extends ContentExample { '' '' '

', [ - MathBlockNode(texSource: "_u^o", nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.453 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'u', nodes: null), - ]), - ])), - KatexVlistRowNode( - verticalOffsetEm: -3.113 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'o', nodes: null), - ]), - ])), - ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.453 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'u', nodes: null), + ]), + ])), + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'o', nodes: null), + ]), + ])), ]), - ]), + ]), ]), ]), ]); - static const mathBlockKatexRaisebox = KatexExample( + static final mathBlockKatexRaisebox = KatexExample.block( 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 - '```math\na\\raisebox{0.25em}{\$b\$}c\n```', + 'a\\raisebox{0.25em}{\$b\$}c', '

' '' 'abc' @@ -391,34 +377,32 @@ class KatexExample extends ContentExample { '' 'b' 'c

', [ - MathBlockNode(texSource: 'a\\raisebox{0.25em}{\$b\$}c', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -3.25 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', nodes: null), - ]), - ])), - ]), - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'c', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a', nodes: null), + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.25 + 3, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'b', nodes: null), + ]), + ])), ]), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'c', nodes: null), ]), ]); - static const mathBlockKatexNegativeMargin = KatexExample( + static final mathBlockKatexNegativeMargin = KatexExample.block( 'math block, KaTeX negative margin', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 - '```math\n1 \\! 2\n```', + '1 \\! 2', '

' '' '1 ⁣21 \\! 2' @@ -428,22 +412,20 @@ class KatexExample extends ContentExample { '1' '' '2

', [ - MathBlockNode(texSource: '1 \\! 2', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: '1', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '2', nodes: null), - ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), text: '1', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: '2', nodes: null), ]), ]), ]); - static const mathBlockKatexLogo = KatexExample( + static final mathBlockKatexLogo = KatexExample.block( 'math block, KaTeX logo', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 - '```math\n\\KaTeX\n```', + '\\KaTeX', '

' '' 'KaTeX' @@ -476,51 +458,49 @@ class KatexExample extends ContentExample { '' '' 'X

', [ - MathBlockNode(texSource: '\\KaTeX', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'K', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'K', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 + text: 'A', nodes: null), + ]), + ])), + ]), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.905 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 - text: 'A', nodes: null), - ]), - ])), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'T', nodes: null), + KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'T', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'E', nodes: null), + ]), + ])), + ]), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.7845 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'E', nodes: null), - ]), - ])), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'X', nodes: null), - ]), + KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'X', nodes: null), ]), ]), ]), @@ -530,10 +510,10 @@ class KatexExample extends ContentExample { ]), ]); - static const mathBlockKatexNegativeMarginsOnVlistRow = KatexExample( + static final mathBlockKatexNegativeMarginsOnVlistRow = KatexExample.block( 'math block, KaTeX negative margins on a vlist row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 - '```math\nX_n\n```', + 'X_n', '

' '' 'XnX_n' @@ -553,39 +533,37 @@ class KatexExample extends ContentExample { '' '' '

', [ - MathBlockNode(texSource: 'X_n', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - marginRightEm: 0.07847, - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'X', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.55 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'n', nodes: null), - ]), - ]), - ]), - ])), - ]), + KatexSpanNode( + styles: KatexSpanStyles( + marginRightEm: 0.07847, + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'X', nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n', nodes: null), + ]), + ]), + ]), + ])), ]), - ]), + ]), ]), ]), ]); @@ -621,7 +599,7 @@ void main() async { final thisFilename = Trace.current().frames[0].uri.path; final source = File(thisFilename).readAsStringSync(); final declaredExamples = RegExp(multiLine: true, - r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*KatexExample\s*(?:\.\s*inline\s*)?\(', + r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*KatexExample\s*(?:\.\s*(?:inline|block)\s*)?\(', ).allMatches(source).map((m) => m.group(1)); final testedExamples = RegExp(multiLine: true, r'^\s*testParseExample\s*\(\s*KatexExample\s*\.\s*(\w+)(?:,\s*skip:\s*true)?\s*\);', diff --git a/test/widgets/katex_test.dart b/test/widgets/katex_test.dart index eb1c776a30..325bcf0b6a 100644 --- a/test/widgets/katex_test.dart +++ b/test/widgets/katex_test.dart @@ -15,7 +15,7 @@ void main() { group('MathBlock', () { group('characters render at specific offsets with specific size', () { - const testCases = <(KatexExample, List<(String, Offset, Size)>, {bool? skip})>[ + final testCases = <(KatexExample, List<(String, Offset, Size)>, {bool? skip})>[ (KatexExample.mathBlockKatexSizing, skip: false, [ ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), From 5e1211b1a445f52617e5db660a3c45e5159586ae Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 15:01:27 -0700 Subject: [PATCH 344/423] katex test [nfc]: Use Dart raw strings to avoid double-backslash This is available to us now that these strings don't include the leading "```math" and trailing "```" lines, and the newlines separating them from the main content. --- test/model/katex_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 581719fdba..7dc850d09a 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -80,7 +80,7 @@ class KatexExample extends ContentExample { static final mathBlockKatexNestedSizing = KatexExample.block( 'math block; KaTeX nested sizing', - '\\tiny {1 \\Huge 2}', + r'\tiny {1 \Huge 2}', '

' '' '12' @@ -108,7 +108,7 @@ class KatexExample extends ContentExample { static final mathBlockKatexDelimSizing = KatexExample.block( 'math block; KaTeX delimiter sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2147135 - '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', + r'⟨ \big( \Big[ \bigg⌈ \Bigg⌊', '

' '' '([' @@ -188,7 +188,7 @@ class KatexExample extends ContentExample { static final mathBlockKatexSuperscript = KatexExample.block( 'math block, KaTeX superscript; single vlist-r, single vertical offset row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 - 'a\'', + "a'", '

' '' 'a' @@ -360,7 +360,7 @@ class KatexExample extends ContentExample { static final mathBlockKatexRaisebox = KatexExample.block( 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 - 'a\\raisebox{0.25em}{\$b\$}c', + r'a\raisebox{0.25em}{$b$}c', '

' '' 'abc' @@ -402,7 +402,7 @@ class KatexExample extends ContentExample { static final mathBlockKatexNegativeMargin = KatexExample.block( 'math block, KaTeX negative margin', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 - '1 \\! 2', + r'1 \! 2', '

' '' '1 ⁣21 \\! 2' @@ -425,7 +425,7 @@ class KatexExample extends ContentExample { static final mathBlockKatexLogo = KatexExample.block( 'math block, KaTeX logo', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 - '\\KaTeX', + r'\KaTeX', '

' '' 'KaTeX' From 4b679d44ff73550d6c8ead9a3d460f9e776f8ff7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 21 Jul 2025 14:28:47 -0700 Subject: [PATCH 345/423] katex test [nfc]: Make a KatexExample.inline similar to .block --- test/model/katex_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 7dc850d09a..21ae51058b 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -14,6 +14,11 @@ import 'content_test.dart'; /// /// For guidance on writing examples, see comments on [ContentExample]. class KatexExample extends ContentExample { + KatexExample.inline(String description, String texSource, String html, + List? expectedNodes) + : super.inline(description, '\$\$ $texSource \$\$', html, + MathInlineNode(texSource: texSource, nodes: expectedNodes)); + KatexExample.block(String description, String texSource, String html, List? expectedNodes) : super(description, '```math\n$texSource\n```', html, From 5e303dcd5e6ca6ec90c755c1ac4e67eaf9e413e1 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 15:09:47 -0700 Subject: [PATCH 346/423] katex [nfc]: On KatexSpanNode constructor let text or nodes be omitted We already have an assert enforcing that exactly one of these is passed with a non-null (i.e. non-default) value. So there's no risk of forgetting to specify a detail that should be specified. --- lib/model/content.dart | 4 +- lib/model/katex.dart | 2 - test/model/content_test.dart | 39 +++----- test/model/katex_test.dart | 182 +++++++++++++++++------------------ 4 files changed, 109 insertions(+), 118 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 28486f634d..94bab3e6ee 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -381,8 +381,8 @@ sealed class KatexNode extends ContentNode { class KatexSpanNode extends KatexNode { const KatexSpanNode({ required this.styles, - required this.text, - required this.nodes, + this.text, + this.nodes, super.debugHtmlNode, }) : assert((text != null) ^ (nodes != null)); diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 6eab8473c7..7e278987f0 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -341,13 +341,11 @@ class _KatexParser { KatexSpanNode child = KatexSpanNode( styles: styles, - text: null, nodes: _parseChildSpans(otherSpans)); if (marginLeftIsNegative) { child = KatexSpanNode( styles: KatexSpanStyles(), - text: null, nodes: [KatexNegativeMarginNode( leftOffsetEm: marginLeftEm!, nodes: [child])]); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index bead811079..24126357b1 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -518,14 +518,13 @@ class ContentExample { ' \\lambda ' '

', MathInlineNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'λ', - nodes: null), + text: 'λ'), ]), ])); @@ -538,14 +537,13 @@ class ContentExample { '\\lambda' '

', [MathBlockNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'λ', - nodes: null), + text: 'λ'), ]), ])]); @@ -563,25 +561,23 @@ class ContentExample { 'b' '

', [ MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', - nodes: null), + text: 'a'), ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', - nodes: null), + text: 'b'), ]), ]), ]); @@ -602,14 +598,13 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'λ', - nodes: null), + text: 'λ'), ]), ]), ])]); @@ -631,25 +626,23 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', - nodes: null), + text: 'a'), ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', - nodes: null), + text: 'b'), ]), ]), ])]); @@ -680,13 +673,13 @@ class ContentExample { originalHeight: null), ]), MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), + text: 'a'), ]), ]), ImageNodeList([ diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 21ae51058b..babfb270f4 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -48,38 +48,38 @@ class KatexExample extends ContentExample { '8' '9' '0

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 - text: '1', nodes: null), + text: '1'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 - text: '2', nodes: null), + text: '2'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 - text: '3', nodes: null), + text: '3'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 - text: '4', nodes: null), + text: '4'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 - text: '5', nodes: null), + text: '5'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 - text: '6', nodes: null), + text: '6'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 - text: '7', nodes: null), + text: '7'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 - text: '8', nodes: null), + text: '8'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: '9', nodes: null), + text: '9'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 - text: '0', nodes: null), + text: '0'), ]), ]); @@ -96,16 +96,16 @@ class KatexExample extends ContentExample { '' '1' '2

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 - text: null, nodes: [ + nodes: [ KatexSpanNode(styles: KatexSpanStyles(), - text: '1', nodes: null), + text: '1'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 - text: '2', nodes: null), + text: '2'), ]), ]), ]); @@ -126,29 +126,29 @@ class KatexExample extends ContentExample { '[' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), KatexSpanNode(styles: KatexSpanStyles(), - text: '⟨', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + text: '⟨'), + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), - text: '(', nodes: null), + text: '('), ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), - text: '[', nodes: null), + text: '['), ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), - text: '⌈', nodes: null), + text: '⌈'), ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), - text: '⌊', nodes: null), + text: '⌊'), ]), ]), ]); @@ -170,23 +170,23 @@ class KatexExample extends ContentExample { '' '' '2

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), KatexSpanNode(styles: KatexSpanStyles(), - text: '1', nodes: null), + text: '1'), KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, nodes: []), + nodes: []), KatexSpanNode(styles: KatexSpanStyles(), - text: ':', nodes: null), + text: ':'), KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, nodes: []), + nodes: []), ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), KatexSpanNode(styles: KatexSpanStyles(), - text: '2', nodes: null), + text: '2'), ]), ]); @@ -212,25 +212,25 @@ class KatexExample extends ContentExample { '' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), + text: 'a'), KatexSpanNode( styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ + nodes: [ KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -3.113 + 2.7, node: KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '′', nodes: null), + nodes: [ + KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: '′'), ]), ]), ])), @@ -264,28 +264,28 @@ class KatexExample extends ContentExample { '' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'x', nodes: null), + text: 'x'), KatexSpanNode( styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ + nodes: [ KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -2.55 + 2.7, node: KatexSpanNode( styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), - text: null, nodes: [ + nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ + nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'n', nodes: null), + text: 'n'), ]), ])), ]), @@ -322,38 +322,38 @@ class KatexExample extends ContentExample { '' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: []), KatexSpanNode( styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ + nodes: [ KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -2.453 + 2.7, node: KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ + nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ + nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'u', nodes: null), + text: 'u'), ]), ])), KatexVlistRowNode( verticalOffsetEm: -3.113 + 2.7, node: KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ + nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ + nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'o', nodes: null), + text: 'o'), ]), ])), ]), @@ -382,25 +382,25 @@ class KatexExample extends ContentExample { '' 'b' 'c

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), + text: 'a'), KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -3.25 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + node: KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', nodes: null), + text: 'b'), ]), ])), ]), KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'c', nodes: null), + text: 'c'), ]), ]); @@ -417,12 +417,12 @@ class KatexExample extends ContentExample { '1' '' '2

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: '1', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(), text: '1'), + KatexSpanNode(styles: KatexSpanStyles(), nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '2', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: '2'), ]), ]), ]); @@ -463,49 +463,49 @@ class KatexExample extends ContentExample { '' '' 'X

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'K', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + text: 'K'), + KatexSpanNode(styles: KatexSpanStyles(), nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -2.905 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + node: KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 - text: 'A', nodes: null), + text: 'A'), ]), ])), ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(), nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'T', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + text: 'T'), + KatexSpanNode(styles: KatexSpanStyles(), nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -2.7845 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + node: KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'E', nodes: null), + text: 'E'), ]), ])), ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(), nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'X', nodes: null), + text: 'X'), ]), ]), ]), @@ -538,31 +538,31 @@ class KatexExample extends ContentExample { '' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexSpanNode( styles: KatexSpanStyles( marginRightEm: 0.07847, fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'X', nodes: null), + text: 'X'), KatexSpanNode( styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ + nodes: [ KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -2.55 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + node: KatexSpanNode(styles: KatexSpanStyles(), nodes: [ KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ + nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ + nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'n', nodes: null), + text: 'n'), ]), ]), ]), From 8a39d0453fdb07424f39185a9205f62eb083050e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 15:14:49 -0700 Subject: [PATCH 347/423] katex [nfc]: Let KatexSpanNode.styles default to empty We have a lot of calls to the KatexSpanNode constructor in our tests, and a large fraction of them pass an empty `styles`. Let that be the default, reducing some noise. It's a natural default, since it effectively means "do nothing". --- lib/model/content.dart | 2 +- lib/model/katex.dart | 1 - test/model/content_test.dart | 16 +++---- test/model/katex_test.dart | 93 +++++++++++++++++------------------- 4 files changed, 53 insertions(+), 59 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 94bab3e6ee..c0a6d3bc9d 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -380,7 +380,7 @@ sealed class KatexNode extends ContentNode { class KatexSpanNode extends KatexNode { const KatexSpanNode({ - required this.styles, + this.styles = const KatexSpanStyles(), this.text, this.nodes, super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 7e278987f0..d7d09d5ea2 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -345,7 +345,6 @@ class _KatexParser { if (marginLeftIsNegative) { child = KatexSpanNode( - styles: KatexSpanStyles(), nodes: [KatexNegativeMarginNode( leftOffsetEm: marginLeftEm!, nodes: [child])]); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 24126357b1..5eaf5500fa 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -518,7 +518,7 @@ class ContentExample { ' \\lambda ' '

', MathInlineNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( @@ -537,7 +537,7 @@ class ContentExample { '\\lambda' '

', [MathBlockNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( @@ -561,7 +561,7 @@ class ContentExample { 'b' '

', [ MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( @@ -571,7 +571,7 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( @@ -598,7 +598,7 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( @@ -626,7 +626,7 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( @@ -636,7 +636,7 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( @@ -673,7 +673,7 @@ class ContentExample { originalHeight: null), ]), MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index babfb270f4..3b0916d3c0 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -48,7 +48,7 @@ class KatexExample extends ContentExample { '8' '9' '0

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 @@ -96,13 +96,12 @@ class KatexExample extends ContentExample { '' '1' '2

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), - text: '1'), + KatexSpanNode(text: '1'), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 text: '2'), @@ -126,26 +125,25 @@ class KatexExample extends ContentExample { '[' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), - KatexSpanNode(styles: KatexSpanStyles(), - text: '⟨'), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(text: '⟨'), + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), text: '('), ]), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), text: '['), ]), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), text: '⌈'), ]), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), text: '⌊'), @@ -170,23 +168,20 @@ class KatexExample extends ContentExample { '' '' '2

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), - text: '1'), + KatexSpanNode(text: '1'), KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.2778), nodes: []), - KatexSpanNode(styles: KatexSpanStyles(), - text: ':'), + KatexSpanNode(text: ':'), KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.2778), nodes: []), ]), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), - text: '2'), + KatexSpanNode(text: '2'), ]), ]); @@ -212,9 +207,9 @@ class KatexExample extends ContentExample { '' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -229,8 +224,8 @@ class KatexExample extends ContentExample { styles: KatexSpanStyles(marginRightEm: 0.05), nodes: [ KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '′'), + KatexSpanNode(nodes: [ + KatexSpanNode(text: '′'), ]), ]), ])), @@ -264,9 +259,9 @@ class KatexExample extends ContentExample { '' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -322,10 +317,10 @@ class KatexExample extends ContentExample { '' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: []), + KatexSpanNode(nodes: [ + KatexSpanNode(nodes: []), KatexSpanNode( styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), nodes: [ @@ -382,7 +377,7 @@ class KatexExample extends ContentExample { '' 'b' 'c

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -390,8 +385,8 @@ class KatexExample extends ContentExample { KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -3.25 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), text: 'b'), @@ -417,12 +412,12 @@ class KatexExample extends ContentExample { '1' '' '2

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: '1'), - KatexSpanNode(styles: KatexSpanStyles(), nodes: []), + KatexSpanNode(text: '1'), + KatexSpanNode(nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '2'), + KatexSpanNode(text: '2'), ]), ]), ]); @@ -463,45 +458,45 @@ class KatexExample extends ContentExample { '' '' 'X

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), text: 'K'), - KatexSpanNode(styles: KatexSpanStyles(), nodes: []), + KatexSpanNode(nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -2.905 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 text: 'A'), ]), ])), ]), - KatexSpanNode(styles: KatexSpanStyles(), nodes: []), + KatexSpanNode(nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), text: 'T'), - KatexSpanNode(styles: KatexSpanStyles(), nodes: []), + KatexSpanNode(nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -2.7845 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), text: 'E'), ]), ])), ]), - KatexSpanNode(styles: KatexSpanStyles(), nodes: []), + KatexSpanNode(nodes: []), KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), @@ -538,9 +533,9 @@ class KatexExample extends ContentExample { '' '' '

', [ - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + KatexSpanNode(nodes: [ KatexSpanNode( styles: KatexSpanStyles( marginRightEm: 0.07847, @@ -552,7 +547,7 @@ class KatexExample extends ContentExample { KatexVlistNode(rows: [ KatexVlistRowNode( verticalOffsetEm: -2.55 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), nodes: [ + node: KatexSpanNode(nodes: [ KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ KatexSpanNode( styles: KatexSpanStyles(marginRightEm: 0.05), From 5f85799648832593a1974540a1214dcfde23962c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 19 Jul 2025 16:34:32 -0700 Subject: [PATCH 348/423] katex [nfc]: Add docs explaining the different content-node classes --- lib/model/content.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/model/content.dart b/lib/model/content.dart index c0a6d3bc9d..78d7af24bc 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -341,6 +341,11 @@ class CodeBlockSpanNode extends ContentNode { } } +/// A complete KaTeX math expression within Zulip content, +/// whether block or inline. +/// +/// The content nodes that are descendants of this node +/// will all be of KaTeX-specific types, such as [KatexNode]. sealed class MathNode extends ContentNode { const MathNode({ super.debugHtmlNode, @@ -374,10 +379,15 @@ sealed class MathNode extends ContentNode { } } +/// A content node that expects a generic KaTeX context from its parent. +/// +/// Each of these will have a [MathNode] as an ancestor. sealed class KatexNode extends ContentNode { const KatexNode({super.debugHtmlNode}); } +/// A generic KaTeX content node, corresponding to any span in KaTeX HTML +/// that we don't otherwise specially handle. class KatexSpanNode extends KatexNode { const KatexSpanNode({ this.styles = const KatexSpanStyles(), @@ -411,6 +421,7 @@ class KatexSpanNode extends KatexNode { } } +/// A KaTeX strut, corresponding to a `span.strut` node in KaTeX HTML. class KatexStrutNode extends KatexNode { const KatexStrutNode({ required this.heightEm, @@ -429,6 +440,12 @@ class KatexStrutNode extends KatexNode { } } +/// A KaTeX "vertical list", corresponding to a `span.vlist-t` in KaTeX HTML. +/// +/// These nodes in KaTeX HTML have a very specific structure. +/// The children of these nodes in our tree correspond in the HTML to +/// certain great-grandchildren (certain `> .vlist-r > .vlist > span`) +/// of the `.vlist-t` node. class KatexVlistNode extends KatexNode { const KatexVlistNode({ required this.rows, @@ -443,6 +460,11 @@ class KatexVlistNode extends KatexNode { } } +/// An element of a KaTeX "vertical list"; a child of a [KatexVlistNode]. +/// +/// These correspond to certain `.vlist-t > .vlist-r > .vlist > span` nodes +/// in KaTeX HTML. The [KatexVlistNode] parent in our tree +/// corresponds to the `.vlist-t` great-grandparent in the HTML. class KatexVlistRowNode extends ContentNode { const KatexVlistRowNode({ required this.verticalOffsetEm, @@ -465,6 +487,11 @@ class KatexVlistRowNode extends ContentNode { } } +/// A KaTeX node corresponding to negative values for `margin-left` +/// or `margin-right` in the inline CSS style of a KaTeX HTML node. +/// +/// The parser synthesizes these as additional nodes, not corresponding +/// directly to any node in the HTML. class KatexNegativeMarginNode extends KatexNode { const KatexNegativeMarginNode({ required this.leftOffsetEm, From dedeee63b941b19ad6536f40a3b56a8825df5e72 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 17 Jul 2025 12:17:19 +0430 Subject: [PATCH 349/423] test store [nfc]: Make changeUserStatuses take a Map instead of a Record --- test/model/test_store.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/model/test_store.dart b/test/model/test_store.dart index e77b5fc2a0..18e41bcfb5 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -275,8 +275,8 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(UserStatusEvent(id: 1, userId: userId, change: change)); } - Future changeUserStatuses(List<(int userId, UserStatusChange change)> changes) async { - for (final (userId, change) in changes) { + Future changeUserStatuses(Map changes) async { + for (final MapEntry(key: userId, value: change) in changes.entries) { await changeUserStatus(userId, change); } } From b06417bdf6a8e1460400beb420bd0930dd2a7a95 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:31:25 +0430 Subject: [PATCH 350/423] msglist: Show user status emoji --- lib/widgets/message_list.dart | 2 + test/widgets/message_list_test.dart | 69 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 26021e108e..3cc767f0c5 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1958,6 +1958,8 @@ class SenderRow extends StatelessWidget { : designVariables.title, ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), + UserStatusEmoji(userId: message.senderId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)), if (sender?.isBot ?? false) ...[ const SizedBox(width: 5), Icon( diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 54c714b34d..87dff86b2a 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -14,6 +14,7 @@ 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/basic.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/message.dart'; @@ -1772,6 +1773,74 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(SenderRow))).findsOne(); + } + + testWidgets('emoji (unicode) & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji (image) & text are set -> emoji is displayed, text is not', (tester) async { + prepareBoringImageHttpClient(); + + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Coding'), + emoji: OptionSome(StatusEmoji(emojiName: 'zulip', + emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.byType(Image)); + check(find.textContaining('Coding')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('longer user name -> emoji stays visible', (tester) async { + final user = eg.user(fullName: 'User with a very very very long name to check if emoji is still visible'); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + group('Muted sender', () { void checkMessage(Message message, {required bool expectIsMuted}) { final mutedLabel = 'Muted user'; From a89541289ddbfce3f47d352e7ce40b12900b2c35 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 21 Jul 2025 17:04:13 -0700 Subject: [PATCH 351/423] test: Add find.text analogue with includePlaceholders option The bulk of this code is copied verbatim from the implementation of `find.text`, in `package:flutter_test/src/finders.dart`. The new logic is just the includePlaceholders flag, and passing it down to where InlineSpan.toPlainText gets called. The latter already has a corresponding flag, which does the rest. --- test/widgets/finders.dart | 102 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 test/widgets/finders.dart diff --git a/test/widgets/finders.dart b/test/widgets/finders.dart new file mode 100644 index 0000000000..fe4111cdbe --- /dev/null +++ b/test/widgets/finders.dart @@ -0,0 +1,102 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Like `find.text` from flutter_test upstream, but with +/// the `includePlaceholders` option. +/// +/// When `includePlaceholders` is true, any [PlaceholderSpan] (for example, +/// any [WidgetSpan]) in the tree will be represented as +/// an "object replacement character", U+FFFC. +/// When `includePlaceholders` is false, such spans will be omitted. +/// +/// TODO(upstream): get `find.text` to accept includePlaceholders +Finder findText(String text, { + bool findRichText = false, + bool includePlaceholders = true, + bool skipOffstage = true, +}) { + return _TextWidgetFinder(text, + findRichText: findRichText, + includePlaceholders: includePlaceholders, + skipOffstage: skipOffstage); +} + +// (Compare the implementation in `package:flutter_test/src/finders.dart`.) +abstract class _MatchTextFinder extends MatchFinder { + _MatchTextFinder({this.findRichText = false, this.includePlaceholders = true, + super.skipOffstage}); + + /// Whether standalone [RichText] widgets should be found or not. + /// + /// Defaults to `false`. + /// + /// If disabled, only [Text] widgets will be matched. [RichText] widgets + /// *without* a [Text] ancestor will be ignored. + /// If enabled, only [RichText] widgets will be matched. This *implicitly* + /// matches [Text] widgets as well since they always insert a [RichText] + /// child. + /// + /// In either case, [EditableText] widgets will also be matched. + final bool findRichText; + + final bool includePlaceholders; + + bool matchesText(String textToMatch); + + @override + bool matches(Element candidate) { + final Widget widget = candidate.widget; + if (widget is EditableText) { + return _matchesEditableText(widget); + } + + if (!findRichText) { + return _matchesNonRichText(widget); + } + // It would be sufficient to always use _matchesRichText if we wanted to + // match both standalone RichText widgets as well as Text widgets. However, + // the find.text() finder used to always ignore standalone RichText widgets, + // which is why we need the _matchesNonRichText method in order to not be + // backwards-compatible and not break existing tests. + return _matchesRichText(widget); + } + + bool _matchesRichText(Widget widget) { + if (widget is RichText) { + return matchesText(widget.text.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesNonRichText(Widget widget) { + if (widget is Text) { + if (widget.data != null) { + return matchesText(widget.data!); + } + assert(widget.textSpan != null); + return matchesText(widget.textSpan!.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesEditableText(EditableText widget) { + return matchesText(widget.controller.text); + } +} + +class _TextWidgetFinder extends _MatchTextFinder { + _TextWidgetFinder(this.text, {super.findRichText, super.includePlaceholders, + super.skipOffstage}); + + final String text; + + @override + String get description => 'text "$text"'; + + @override + bool matchesText(String textToMatch) { + return textToMatch == text; + } +} From 395eb91da39ac180953b5fef770769c90936eba8 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:32:27 +0430 Subject: [PATCH 352/423] recent-dms: Show user status emoji in recent DMs page Status emojis are only shown for self-1:1 and 1:1 conversation items. They're ignored for group conversations as that's what the Web does. --- lib/widgets/recent_dm_conversations.dart | 24 +++-- .../widgets/recent_dm_conversations_test.dart | 88 ++++++++++++++++++- 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 5526557589..96ecfdbef4 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -104,23 +104,29 @@ class RecentDmConversationsItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); - final String title; + final InlineSpan title; final Widget avatar; int? userIdForPresence; switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage] case []: - title = store.selfUser.fullName; + title = TextSpan(text: store.selfUser.fullName, children: [ + UserStatusEmoji.asWidgetSpan(userId: store.selfUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), + ]); avatar = AvatarImage(userId: store.selfUserId, size: _avatarSize); case [var otherUserId]: - title = store.userDisplayName(otherUserId); + title = TextSpan(text: store.userDisplayName(otherUserId), children: [ + UserStatusEmoji.asWidgetSpan(userId: otherUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), + ]); 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']) - // // 'Chris、Greg、Alya' - title = narrow.otherRecipientIds.map(store.userDisplayName) - .join(', '); + title = TextSpan( + // TODO(i18n): List formatting, like you can do in JavaScript: + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) + // // 'Chris、Greg、Alya' + text: narrow.otherRecipientIds.map(store.userDisplayName).join(', ')); avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, child: Center( child: Icon(color: designVariables.avatarPlaceholderIcon, @@ -148,7 +154,7 @@ class RecentDmConversationsItem extends StatelessWidget { const SizedBox(width: 8), Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( + child: Text.rich( style: TextStyle( fontSize: 17, height: (20 / 17), diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index e898218e6e..16c1057c19 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -5,7 +5,9 @@ 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'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; @@ -20,10 +22,13 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; import 'content_checks.dart'; +import 'finders.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required List dmMessages, required List users, @@ -36,7 +41,7 @@ Future setupPage(WidgetTester tester, { selfUser ??= eg.selfUser; final selfAccount = eg.account(user: selfUser); await testBinding.globalStore.add(selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(selfAccount.id); + store = await testBinding.globalStore.perAccount(selfAccount.id); await store.addUser(selfUser); for (final user in users) { @@ -173,8 +178,9 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` final widget = tester.widget(find.descendant( of: find.byType(RecentDmConversationsItem), - matching: find.text(expectedText), - )); + // The title might contain a WidgetSpan (for status emoji); exclude + // the resulting placeholder character from the text to be matched. + matching: findText(expectedText, includePlaceholders: false))); if (expectedLines != null) { final renderObject = tester.renderObject(find.byWidget(widget)); check(renderObject.size.height).equals( @@ -183,6 +189,16 @@ void main() { } } + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(RecentDmConversationsItem))).findsOne(); + } + Future markMessageAsRead(WidgetTester tester, Message message) async { final store = await testBinding.globalStore.perAccount( testBinding.globalStore.accounts.single.id); @@ -229,6 +245,31 @@ void main() { checkTitle(tester, name, 2); }); + group('User status', () { + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.selfUser, to: []); await setupPage(tester, users: [], dmMessages: [message]); @@ -289,6 +330,33 @@ void main() { checkTitle(tester, user.fullName, 2); }); + group('User status', () { + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await setupPage(tester, users: [], dmMessages: [message]); @@ -377,6 +445,20 @@ void main() { checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); }); + testWidgets('status emoji & text are set -> none of them is displayed', (tester) async { + final users = usersList(4); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message]); + await store.changeUserStatus(users.first.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); await setupPage(tester, users: [], dmMessages: [message]); From c99bd22c67af6a0229265a2140106489764a2051 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:37:00 +0430 Subject: [PATCH 353/423] new-dm: Show user status emoji --- lib/widgets/new_dm_sheet.dart | 8 +- test/widgets/new_dm_sheet_test.dart | 123 ++++++++++++++++++++++------ 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index 56f098790f..e67b62e382 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -317,6 +317,8 @@ class _SelectedUserChip extends StatelessWidget { fontSize: 16, height: 16 / 16, color: designVariables.labelMenuButton)))), + UserStatusEmoji(userId: userId, size: 16, + padding: EdgeInsetsDirectional.only(end: 4)), ]))); } } @@ -415,7 +417,11 @@ class _NewDmUserListItem extends StatelessWidget { Avatar(userId: userId, size: 32, borderRadius: 3), SizedBox(width: 8), Expanded( - child: Text(store.userDisplayName(userId), + child: Text.rich( + TextSpan(text: store.userDisplayName(userId), children: [ + UserStatusEmoji.asWidgetSpan(userId: userId, fontSize: 17, + textScaler: MediaQuery.textScalerOf(context)), + ]), style: TextStyle( fontSize: 17, height: 19 / 17, diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index fc9567d78d..72a0ecab18 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -3,6 +3,8 @@ 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/basic.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; @@ -17,8 +19,11 @@ import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; +import 'finders.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupSheet(WidgetTester tester, { required List users, List? mutedUserIds, @@ -30,7 +35,7 @@ Future setupSheet(WidgetTester tester, { ..onPushed = (route, _) => lastPushedRoute = route; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users); if (mutedUserIds != null) { await store.setMutedUsers(mutedUserIds); @@ -65,7 +70,8 @@ void main() { } Finder findUserTile(User user) => - find.widgetWithText(InkWell, user.fullName).first; + find.ancestor(of: findText(user.fullName, includePlaceholders: false), + matching: find.byType(InkWell)).first; Finder findUserChip(User user) { final findAvatar = find.byWidgetPredicate((widget) => @@ -120,23 +126,23 @@ void main() { testWidgets('shows all non-muted users initially', (tester) async { await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsOne(); - check(find.text('Charlie Carter')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); - check(find.text('Someone Muted')).findsNothing(); - check(find.text('Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); }); 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(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); }); // TODO test sorting by recent-DMs @@ -146,11 +152,11 @@ void main() { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); await tester.enterText(find.byType(TextField), 'ALICE'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); }); testWidgets('partial name and last name search handling', (tester) async { @@ -158,31 +164,31 @@ void main() { 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(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, '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(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, '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(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, '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(); + check(findText(includePlaceholders: false, 'No users found')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); }); testWidgets('search text clears when user is selected', (tester) async { @@ -252,7 +258,7 @@ void main() { await tester.tap(findUserTile(eg.selfUser)); await tester.pump(); checkUserSelected(tester, eg.selfUser, true); - check(find.text(eg.selfUser.fullName)).findsExactly(2); + check(findText(includePlaceholders: false, eg.selfUser.fullName)).findsExactly(2); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -264,7 +270,7 @@ void main() { final otherUser = eg.user(fullName: 'Other User'); await setupSheet(tester, users: [eg.selfUser, otherUser]); - check(find.text(eg.selfUser.fullName)).findsOne(); + check(findText(includePlaceholders: false, eg.selfUser.fullName)).findsOne(); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -285,6 +291,69 @@ void main() { }); }); + group('User status', () { + void checkFindsTileStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final tileStatusEmojiFinder = find.descendant(of: findUserTile(user), + matching: statusEmojiFinder); + check(tester.widget(tileStatusEmojiFinder) + .neverAnimate).isTrue(); + check(tileStatusEmojiFinder).findsOne(); + } + + void checkFindsChipStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final chipStatusEmojiFinder = find.descendant(of: findUserChip(user), + matching: statusEmojiFinder); + check(tester.widget(chipStatusEmojiFinder) + .neverAnimate).isTrue(); + check(chipStatusEmojiFinder).findsOne(); + } + + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(findUserChip(user)).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(findUserChip(user)).findsOne(); + checkFindsChipStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(findUserTile(user)).findsOne(); + check(findUserChip(user)).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + check(findUserTile(user)).findsOne(); + check(findUserChip(user)).findsOne(); + check(find.textContaining('Busy')).findsNothing(); + }); + }); + group('navigation to DM Narrow', () { Future runAndCheck(WidgetTester tester, { required List users, From 22f17e341b13c8b2702732e47e1047f03749796d Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 17 Jul 2025 22:01:04 +0430 Subject: [PATCH 354/423] autocomplete [nfc]: Make MentionAutocompleteItem visibleForTesting --- lib/widgets/autocomplete.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 526c7edfb1..78245a85f9 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -223,7 +223,7 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem( + MentionAutocompleteResult() => MentionAutocompleteItem( option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; @@ -238,8 +238,13 @@ class ComposeAutocomplete extends AutocompleteField Date: Fri, 27 Jun 2025 02:38:42 +0430 Subject: [PATCH 355/423] autocomplete: Show user status emoji in user-mention autocomplete --- lib/widgets/autocomplete.dart | 31 ++++++++-------- test/widgets/autocomplete_test.dart | 55 ++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 78245a85f9..7445370e7a 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -277,29 +277,35 @@ class MentionAutocompleteItem extends StatelessWidget { Widget avatar; String label; + Widget? emoji; String? sublabel; switch (option) { case UserMentionAutocompleteResult(:var userId): avatar = Avatar(userId: userId, size: 36, borderRadius: 4); label = store.userDisplayName(userId); + emoji = UserStatusEmoji(userId: userId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)); sublabel = store.userDisplayEmail(userId); case WildcardMentionAutocompleteResult(:var wildcardOption): avatar = SizedBox.square(dimension: 36, child: const Icon(ZulipIcons.three_person, size: 24)); label = wildcardOption.canonicalString; + emoji = null; sublabel = wildcardSublabel(wildcardOption, context: context, store: store); } - final labelWidget = Text( - label, - style: TextStyle( - fontSize: 18, - height: 20 / 18, - color: designVariables.contextMenuItemLabel, - ).merge(weightVariableTextStyle(context, - wght: sublabel == null ? 500 : 600)), - overflow: TextOverflow.ellipsis, - maxLines: 1); + final labelWidget = Row(children: [ + Flexible(child: Text(label, + style: TextStyle( + fontSize: 18, + height: 20 / 18, + color: designVariables.contextMenuItemLabel, + ).merge(weightVariableTextStyle(context, + wght: sublabel == null ? 500 : 600)), + overflow: TextOverflow.ellipsis, + maxLines: 1)), + ?emoji, + ]); final sublabelWidget = sublabel == null ? null : Text( sublabel, @@ -318,10 +324,7 @@ class MentionAutocompleteItem extends StatelessWidget { Expanded(child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - labelWidget, - if (sublabelWidget != null) sublabelWidget, - ])), + children: [labelWidget, ?sublabelWidget])), ])); } } diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 573921b663..118db51c18 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -7,12 +7,14 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -25,6 +27,8 @@ import '../model/test_store.dart'; import '../test_images.dart'; import 'test_app.dart'; +late PerAccountStore store; + /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// /// Also adds [users] to the [PerAccountStore], @@ -44,7 +48,7 @@ Future setupToComposeInput(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); final connection = store.connection as FakeApiConnection; @@ -202,6 +206,55 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(MentionAutocompleteItem))).findsOne(); + } + + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + check(find.textContaining('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { check(find.text(wildcard.canonicalString)).findsExactly(expected ? 1 : 0); } From df8c5f86eab021d866768c439faffa322b038c64 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:39:27 +0430 Subject: [PATCH 356/423] profile: Show user status Fixes: #197 --- lib/widgets/profile.dart | 14 ++++++++++++- lib/widgets/theme.dart | 8 ++++++++ test/widgets/profile_test.dart | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index b4c610b71f..6576e2a0fe 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -16,6 +16,7 @@ import 'page.dart'; import 'remote_settings.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; class _TextStyles { static const primaryFieldText = TextStyle(fontSize: 20); @@ -51,6 +52,7 @@ class ProfilePage extends StatelessWidget { final nameStyle = _TextStyles.primaryFieldText .merge(weightVariableTextStyle(context, wght: 700)); + final userStatus = store.getUserStatus(userId); final displayEmail = store.userDisplayEmail(userId); final items = [ Center( @@ -73,9 +75,20 @@ class ProfilePage extends StatelessWidget { ), // TODO write a test where the user is muted; check this and avatar TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), + UserStatusEmoji.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + neverAnimate: false, + ), ]), textAlign: TextAlign.center, style: nameStyle), + if (userStatus.text != null) + Text(userStatus.text!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, height: 22 / 18, + color: DesignVariables.of(context).userStatusText)), if (displayEmail != null) Text(displayEmail, textAlign: TextAlign.center, @@ -83,7 +96,6 @@ class ProfilePage extends StatelessWidget { Text(roleToLabel(user.role, zulipLocalizations), textAlign: TextAlign.center, style: _TextStyles.primaryFieldText), - // TODO(#197) render user status // TODO(#196) render active status // TODO(#292) render user local time diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 6039072116..8e70f28e3c 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -213,6 +213,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), unreadCountBadgeTextForChannel: Colors.black.withValues(alpha: 0.9), + userStatusText: const Color(0xff808080), ); static final dark = DesignVariables._( @@ -309,6 +310,8 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.75).toColor(), unreadCountBadgeTextForChannel: Colors.white.withValues(alpha: 0.9), + // TODO(design-dark) unchanged in dark theme? + userStatusText: const Color(0xff808080), ); DesignVariables._({ @@ -388,6 +391,7 @@ class DesignVariables extends ThemeExtension { required this.subscriptionListHeaderLine, required this.subscriptionListHeaderText, required this.unreadCountBadgeTextForChannel, + required this.userStatusText, }); /// The [DesignVariables] from the context's active theme. @@ -480,6 +484,7 @@ class DesignVariables extends ThemeExtension { final Color subscriptionListHeaderLine; final Color subscriptionListHeaderText; final Color unreadCountBadgeTextForChannel; + final Color userStatusText; // In Figma, but unnamed. @override DesignVariables copyWith({ @@ -559,6 +564,7 @@ class DesignVariables extends ThemeExtension { Color? subscriptionListHeaderLine, Color? subscriptionListHeaderText, Color? unreadCountBadgeTextForChannel, + Color? userStatusText, }) { return DesignVariables._( background: background ?? this.background, @@ -637,6 +643,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, subscriptionListHeaderText: subscriptionListHeaderText ?? this.subscriptionListHeaderText, unreadCountBadgeTextForChannel: unreadCountBadgeTextForChannel ?? this.unreadCountBadgeTextForChannel, + userStatusText: userStatusText ?? this.userStatusText, ); } @@ -722,6 +729,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, subscriptionListHeaderText: Color.lerp(subscriptionListHeaderText, other.subscriptionListHeaderText, t)!, unreadCountBadgeTextForChannel: Color.lerp(unreadCountBadgeTextForChannel, other.unreadCountBadgeTextForChannel, t)!, + userStatusText: Color.lerp(userStatusText, other.userStatusText, t)!, ); } } diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 61a85cb63e..0d6fcdfef4 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/button.dart'; @@ -99,6 +100,7 @@ void main() { check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); + // Tests for user status are in their own test group. check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); }); @@ -378,6 +380,40 @@ void main() { }); }); + group('user status', () { + testWidgets('non-self profile, status set: status info appears', (tester) async { + await setupPage(tester, users: [eg.otherUser], pageUserId: eg.otherUser.userId); + await store.changeUserStatus(eg.otherUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(find.text('Busy')).findsOne(); + }); + + testWidgets('self-profile, status set: status info appears', (tester) async { + await setupPage(tester, users: [eg.selfUser], pageUserId: eg.selfUser.userId); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(find.text('Busy')).findsOne(); + }); + }); + group('invisible mode', () { final findRow = find.widgetWithText(ZulipMenuItemButton, 'Invisible mode'); final findToggle = find.descendant(of: findRow, matching: find.byType(Toggle)); From e9bbf4edaf738e23771c693b5e0eff2e455ff3b2 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 21:08:12 -0700 Subject: [PATCH 357/423] readme: Remove a "this beta app" reference --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d0517f9442..93273edc88 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Two specific points to expand on: [commit-style]: https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html -## Getting started in developing this beta app +## Getting started in developing ### Setting up From 608a24f39e671c64b1208cecd1067c74991aaf93 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 22 Jul 2025 14:34:14 -0700 Subject: [PATCH 358/423] test [nfc]: Consolidate a widgets/checks.dart for trivial checks-extensions I've long had a recurring small annoyance that the "foo_checks.dart" file interferes with tab-completion for "foo_test.dart". For example as mentioned in this comment: https://github.com/zulip/zulip-flutter/pull/1608#discussion_r2155918312 So here's an approach to solve that, beginning with the "checks" files in test/widgets/. For updating the imports, I used a bit of Perl: $ perl -i -0pe ' s,import .\K(?!dialog)\w*_checks.dart,checks.dart,g ' test/widgets/*_test.dart --- test/notifications/open_test.dart | 3 +- test/widgets/action_sheet_test.dart | 2 +- test/widgets/app_test.dart | 2 +- test/widgets/channel_colors_checks.dart | 12 --- test/widgets/channel_colors_test.dart | 2 +- test/widgets/checks.dart | 86 +++++++++++++++++++ test/widgets/compose_box_checks.dart | 26 ------ test/widgets/compose_box_test.dart | 2 +- test/widgets/content_checks.dart | 19 ---- test/widgets/content_test.dart | 3 +- test/widgets/home_test.dart | 3 +- test/widgets/login_test.dart | 2 +- test/widgets/message_list_checks.dart | 8 -- test/widgets/message_list_test.dart | 5 +- test/widgets/page_checks.dart | 11 --- test/widgets/profile_page_checks.dart | 6 -- test/widgets/profile_test.dart | 4 +- .../widgets/recent_dm_conversations_test.dart | 4 +- test/widgets/store_checks.dart | 8 -- test/widgets/unread_count_badge_checks.dart | 10 --- 20 files changed, 97 insertions(+), 121 deletions(-) delete mode 100644 test/widgets/channel_colors_checks.dart create mode 100644 test/widgets/checks.dart delete mode 100644 test/widgets/compose_box_checks.dart delete mode 100644 test/widgets/content_checks.dart delete mode 100644 test/widgets/message_list_checks.dart delete mode 100644 test/widgets/page_checks.dart delete mode 100644 test/widgets/profile_page_checks.dart delete mode 100644 test/widgets/store_checks.dart delete mode 100644 test/widgets/unread_count_badge_checks.dart diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index a2c14ca20a..cdfd8ef361 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -22,9 +22,8 @@ import '../model/binding.dart'; import '../model/narrow_checks.dart'; import '../stdlib_checks.dart'; import '../test_navigation.dart'; +import '../widgets/checks.dart'; import '../widgets/dialog_checks.dart'; -import '../widgets/message_list_checks.dart'; -import '../widgets/page_checks.dart'; import 'display_test.dart'; Map messageApnsPayload( diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index bc9b53711f..469634b7aa 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -44,7 +44,7 @@ import '../stdlib_checks.dart'; import '../test_clipboard.dart'; import '../test_images.dart'; import '../test_share_plus.dart'; -import 'compose_box_checks.dart'; +import 'checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index 7b1388dbec..53c959321e 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -17,7 +17,7 @@ import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; import 'dialog_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; import 'test_app.dart'; void main() { diff --git a/test/widgets/channel_colors_checks.dart b/test/widgets/channel_colors_checks.dart deleted file mode 100644 index 8c9e8e37ec..0000000000 --- a/test/widgets/channel_colors_checks.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'dart:ui'; - -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/channel_colors.dart'; - -extension ChannelColorSwatchChecks on Subject { - Subject get base => has((s) => s.base, 'base'); - Subject get unreadCountBadgeBackground => has((s) => s.unreadCountBadgeBackground, 'unreadCountBadgeBackground'); - Subject get iconOnPlainBackground => has((s) => s.iconOnPlainBackground, 'iconOnPlainBackground'); - Subject get iconOnBarBackground => has((s) => s.iconOnBarBackground, 'iconOnBarBackground'); - Subject get barBackground => has((s) => s.barBackground, 'barBackground'); -} diff --git a/test/widgets/channel_colors_test.dart b/test/widgets/channel_colors_test.dart index 46d3527def..c25707508a 100644 --- a/test/widgets/channel_colors_test.dart +++ b/test/widgets/channel_colors_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/widgets/channel_colors.dart'; -import 'channel_colors_checks.dart'; +import 'checks.dart'; void main() { group('ChannelColorSwatches', () { diff --git a/test/widgets/checks.dart b/test/widgets/checks.dart new file mode 100644 index 0000000000..a22099a42a --- /dev/null +++ b/test/widgets/checks.dart @@ -0,0 +1,86 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/unread_count_badge.dart'; + +extension ChannelColorSwatchChecks on Subject { + Subject get base => has((s) => s.base, 'base'); + Subject get unreadCountBadgeBackground => has((s) => s.unreadCountBadgeBackground, 'unreadCountBadgeBackground'); + Subject get iconOnPlainBackground => has((s) => s.iconOnPlainBackground, 'iconOnPlainBackground'); + Subject get iconOnBarBackground => has((s) => s.iconOnBarBackground, 'iconOnBarBackground'); + Subject get barBackground => has((s) => s.barBackground, 'barBackground'); +} + +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 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'); +} + +extension ComposeContentControllerChecks on Subject { + Subject> get validationErrors => has((c) => c.validationErrors, 'validationErrors'); +} + +extension RealmContentNetworkImageChecks on Subject { + Subject get src => has((i) => i.src, 'src'); + // TODO others +} + +extension AvatarImageChecks on Subject { + Subject get userId => has((i) => i.userId, 'userId'); +} + +extension AvatarShapeChecks on Subject { + Subject get size => has((i) => i.size, 'size'); + Subject get borderRadius => has((i) => i.borderRadius, 'borderRadius'); + Subject get child => has((i) => i.child, 'child'); +} + +extension MessageListPageChecks on Subject { + Subject get initNarrow => has((x) => x.initNarrow, 'initNarrow'); + Subject get initAnchorMessageId => has((x) => x.initAnchorMessageId, 'initAnchorMessageId'); +} + +extension WidgetRouteChecks on Subject> { + Subject get page => has((x) => x.page, 'page'); +} + +extension AccountRouteChecks on Subject> { + Subject get accountId => has((x) => x.accountId, 'accountId'); +} + +extension ProfilePageChecks on Subject { + Subject get userId => has((x) => x.userId, 'userId'); +} + +extension PerAccountStoreWidgetChecks on Subject { + Subject get accountId => has((x) => x.accountId, 'accountId'); + Subject get child => has((x) => x.child, 'child'); +} + +extension UnreadCountBadgeChecks on Subject { + Subject get count => has((b) => b.count, 'count'); + Subject get bold => has((b) => b.bold, 'bold'); + Subject get backgroundColor => has((b) => b.backgroundColor, 'backgroundColor'); +} diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart deleted file mode 100644 index 349e8cd971..0000000000 --- a/test/widgets/compose_box_checks.dart +++ /dev/null @@ -1,26 +0,0 @@ -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 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'); -} - -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 d1f9c33484..dd7a71d297 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -37,7 +37,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 'checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; diff --git a/test/widgets/content_checks.dart b/test/widgets/content_checks.dart deleted file mode 100644 index 1faf0e2d62..0000000000 --- a/test/widgets/content_checks.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; - -import 'package:zulip/widgets/content.dart'; - -extension RealmContentNetworkImageChecks on Subject { - Subject get src => has((i) => i.src, 'src'); - // TODO others -} - -extension AvatarImageChecks on Subject { - Subject get userId => has((i) => i.userId, 'userId'); -} - -extension AvatarShapeChecks on Subject { - Subject get size => has((i) => i.size, 'size'); - Subject get borderRadius => has((i) => i.borderRadius, 'borderRadius'); - Subject get child => has((i) => i.child, 'child'); -} diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index e615dc7b81..4995317890 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -28,9 +28,8 @@ import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; +import 'checks.dart'; import 'dialog_checks.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; import 'test_app.dart'; /// Simulate a nested "inner" span's style by merging all ancestor-span diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 1b5c8ad8b5..bf207155b7 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -24,8 +24,7 @@ import '../model/binding.dart'; import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; import 'test_app.dart'; void main () { diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart index a5109ba5db..1ff971072c 100644 --- a/test/widgets/login_test.dart +++ b/test/widgets/login_test.dart @@ -23,7 +23,7 @@ import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; import 'dialog_checks.dart'; -import 'page_checks.dart'; +import 'checks.dart'; void main() { TestZulipBinding.ensureInitialized(); diff --git a/test/widgets/message_list_checks.dart b/test/widgets/message_list_checks.dart deleted file mode 100644 index 0f736466f1..0000000000 --- a/test/widgets/message_list_checks.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:zulip/model/narrow.dart'; -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 87dff86b2a..b491777e8e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -44,11 +44,8 @@ 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 'checks.dart'; import 'dialog_checks.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; import 'test_app.dart'; void main() { diff --git a/test/widgets/page_checks.dart b/test/widgets/page_checks.dart deleted file mode 100644 index a3692273bf..0000000000 --- a/test/widgets/page_checks.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; -import 'package:zulip/widgets/page.dart'; - -extension WidgetRouteChecks on Subject> { - Subject get page => has((x) => x.page, 'page'); -} - -extension AccountRouteChecks on Subject> { - Subject get accountId => has((x) => x.accountId, 'accountId'); -} diff --git a/test/widgets/profile_page_checks.dart b/test/widgets/profile_page_checks.dart deleted file mode 100644 index bc08b43ec1..0000000000 --- a/test/widgets/profile_page_checks.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/profile.dart'; - -extension ProfilePageChecks on Subject { - Subject get userId => has((x) => x.userId, 'userId'); -} diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 0d6fcdfef4..e03b665cda 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -27,9 +27,7 @@ import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; -import 'profile_page_checks.dart'; +import 'checks.dart'; import 'test_app.dart'; late PerAccountStore store; diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 16c1057c19..4f524f33f4 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -21,10 +21,8 @@ import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; -import 'content_checks.dart'; +import 'checks.dart'; import 'finders.dart'; -import 'message_list_checks.dart'; -import 'page_checks.dart'; import 'test_app.dart'; late PerAccountStore store; diff --git a/test/widgets/store_checks.dart b/test/widgets/store_checks.dart deleted file mode 100644 index 9754654556..0000000000 --- a/test/widgets/store_checks.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; -import 'package:zulip/widgets/store.dart'; - -extension PerAccountStoreWidgetChecks on Subject { - Subject get accountId => has((x) => x.accountId, 'accountId'); - Subject get child => has((x) => x.child, 'child'); -} diff --git a/test/widgets/unread_count_badge_checks.dart b/test/widgets/unread_count_badge_checks.dart deleted file mode 100644 index dcd3f99d74..0000000000 --- a/test/widgets/unread_count_badge_checks.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:ui'; - -import 'package:checks/checks.dart'; -import 'package:zulip/widgets/unread_count_badge.dart'; - -extension UnreadCountBadgeChecks on Subject { - Subject get count => has((b) => b.count, 'count'); - Subject get bold => has((b) => b.bold, 'bold'); - Subject get backgroundColor => has((b) => b.backgroundColor, 'backgroundColor'); -} From f4094e37b5aa35f7e4efc229307c3a24e09ff75c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 22 Jul 2025 15:02:39 -0700 Subject: [PATCH 359/423] test [nfc]: Consolidate per-file checks-extensions to widgets/checks.dart too Also fix the name of one such extension; and make a local class in one test private, so that it's clear directly from a grep that its checks-extension isn't another one to consolidate. The grep that found these, and confirmed there are no more, is: $ git grep 'on Subject<[^_]' test/widgets/'*'_test.dart --- test/widgets/action_sheet_test.dart | 5 ----- test/widgets/checks.dart | 11 +++++++++++ test/widgets/emoji_reaction_test.dart | 6 +----- test/widgets/store_test.dart | 8 ++++---- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 469634b7aa..3ca737dd40 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -26,7 +26,6 @@ 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'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; @@ -1842,7 +1841,3 @@ void main() { }); }); } - -extension UnicodeEmojiWidgetChecks on Subject { - Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); -} diff --git a/test/widgets/checks.dart b/test/widgets/checks.dart index a22099a42a..3ea33750e1 100644 --- a/test/widgets/checks.dart +++ b/test/widgets/checks.dart @@ -1,10 +1,13 @@ import 'package:checks/checks.dart'; import 'package:flutter/widgets.dart'; +import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/widgets/channel_colors.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; +import 'package:zulip/widgets/emoji_reaction.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/profile.dart'; @@ -84,3 +87,11 @@ extension UnreadCountBadgeChecks on Subject { Subject get bold => has((b) => b.bold, 'bold'); Subject get backgroundColor => has((b) => b.backgroundColor, 'backgroundColor'); } + +extension UnicodeEmojiWidgetChecks on Subject { + Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); +} + +extension EmojiPickerListEntryChecks on Subject { + Subject get emoji => has((x) => x.emoji, 'emoji'); +} diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 6bfa5e2fac..5948e6828c 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -13,7 +13,6 @@ import 'package:legacy_checks/legacy_checks.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/realm.dart'; -import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; @@ -29,6 +28,7 @@ import '../model/emoji_test.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; +import 'checks.dart'; import 'content_test.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -582,7 +582,3 @@ void main() { }); }); } - -extension EmojiPickerListItemChecks on Subject { - Subject get emoji => has((x) => x.emoji, 'emoji'); -} diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index 74b3dd68ad..e2c9be2d18 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -24,10 +24,10 @@ class MyWidgetWithMixin extends StatefulWidget { const MyWidgetWithMixin({super.key}); @override - State createState() => MyWidgetWithMixinState(); + State createState() => _MyWidgetWithMixinState(); } -class MyWidgetWithMixinState extends State with PerAccountStoreAwareStateMixin { +class _MyWidgetWithMixinState extends State with PerAccountStoreAwareStateMixin { int anyDepChangeCounter = 0; int storeChangeCounter = 0; @@ -50,7 +50,7 @@ class MyWidgetWithMixinState extends State with PerAccountSto } } -extension MyWidgetWithMixinStateChecks on Subject { +extension _MyWidgetWithMixinStateChecks on Subject<_MyWidgetWithMixinState> { Subject get anyDepChangeCounter => has((w) => w.anyDepChangeCounter, 'anyDepChangeCounter'); Subject get storeChangeCounter => has((w) => w.storeChangeCounter, 'storeChangeCounter'); } @@ -334,7 +334,7 @@ void main() { }); testWidgets('PerAccountStoreAwareStateMixin', (tester) async { - final widgetWithMixinKey = GlobalKey(); + final widgetWithMixinKey = GlobalKey<_MyWidgetWithMixinState>(); final accountId = eg.selfAccount.id; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); From ae2b9a21728870fdf28b862359de7f81c5be20fa Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 22 Jul 2025 14:27:31 -0700 Subject: [PATCH 360/423] user [nfc]: Split avatar, presence, status widgets out from content.dart These started here with Avatar, one little widget which we didn't have a more natural home for and so tucked in with the content widgets. That gradually grew from 34 lines to 159 lines, across several widgets. Then when we built widgets for presence and user status, we put them here as they're natural companions of the avatar. Now it's 367 lines in total, none of it really about message content; so give this all its own separate file. --- lib/widgets/autocomplete.dart | 2 +- lib/widgets/content.dart | 367 ----------------- lib/widgets/home.dart | 2 +- lib/widgets/lightbox.dart | 1 + lib/widgets/message_list.dart | 1 + lib/widgets/new_dm_sheet.dart | 2 +- lib/widgets/profile.dart | 1 + lib/widgets/recent_dm_conversations.dart | 2 +- lib/widgets/user.dart | 374 ++++++++++++++++++ test/widgets/action_sheet_test.dart | 1 + test/widgets/autocomplete_test.dart | 2 +- test/widgets/checks.dart | 1 + test/widgets/content_test.dart | 64 --- test/widgets/lightbox_test.dart | 1 + test/widgets/message_list_test.dart | 1 + test/widgets/new_dm_sheet_test.dart | 2 +- test/widgets/profile_test.dart | 1 + .../widgets/recent_dm_conversations_test.dart | 2 +- test/widgets/user_test.dart | 82 ++++ 19 files changed, 471 insertions(+), 438 deletions(-) create mode 100644 lib/widgets/user.dart create mode 100644 test/widgets/user_test.dart diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 7445370e7a..cb5d9d078c 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/emoji.dart'; import '../model/store.dart'; -import 'content.dart'; import 'emoji.dart'; import 'icons.dart'; import 'store.dart'; @@ -13,6 +12,7 @@ import '../model/narrow.dart'; import 'compose_box.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; abstract class AutocompleteField extends StatefulWidget { const AutocompleteField({ diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 551956966e..5d6dfa5084 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -10,16 +10,11 @@ import 'package:intl/intl.dart' as intl; 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/emoji.dart'; import '../model/internal_link.dart'; -import '../model/presence.dart'; import 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; -import 'emoji.dart'; import 'icons.dart'; import 'inset_shadow.dart'; import 'katex.dart'; @@ -1542,368 +1537,6 @@ class RealmContentNetworkImage extends StatelessWidget { } } -/// A rounded square with size [size] showing a user's avatar. -class Avatar extends StatelessWidget { - const Avatar({ - super.key, - required this.userId, - required this.size, - required this.borderRadius, - this.backgroundColor, - this.showPresence = true, - this.replaceIfMuted = true, - }); - - final int userId; - final double size; - final double borderRadius; - final Color? backgroundColor; - final bool showPresence; - final bool replaceIfMuted; - - @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, replaceIfMuted: replaceIfMuted)); - } -} - -/// The appropriate avatar image for a user ID. -/// -/// If the user isn't found, gives a [SizedBox.shrink]. -/// -/// Wrap this with [AvatarShape]. -class AvatarImage extends StatelessWidget { - const AvatarImage({ - 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) { - final store = PerAccountStoreWidget.of(context); - final user = store.getUser(userId); - - if (user == null) { // TODO(log) - 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), - }; - - if (resolvedUrl == null) { - return const SizedBox.shrink(); - } - - final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl); - final physicalSize = (MediaQuery.devicePixelRatioOf(context) * size).ceil(); - - return RealmContentNetworkImage( - avatarUrl.get(physicalSize), - filterQuality: FilterQuality.medium, - fit: BoxFit.cover, - ); - } -} - -/// 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] -/// 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) { - // (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))); - } -} - -/// A user status emoji to be displayed in different parts of the app. -/// -/// Use [padding] to control the padding of status emoji from neighboring -/// widgets. -/// When there is no status emoji to be shown, the padding will be omitted too. -/// -/// Use [neverAnimate] to forcefully disable the animation for animated emojis. -/// Defaults to true. -class UserStatusEmoji extends StatelessWidget { - const UserStatusEmoji({ - super.key, - required this.userId, - required this.size, - this.padding = EdgeInsets.zero, - this.neverAnimate = true, - }); - - final int userId; - final double size; - final EdgeInsetsGeometry padding; - final bool neverAnimate; - - static const double _spanPadding = 4; - - /// Creates a [WidgetSpan] with a [UserStatusEmoji], for use in rich text; - /// before or after a text span. - /// - /// Use [position] to tell the emoji span where it is located relative to - /// another span, so that it can adjust the necessary padding from it. - static InlineSpan asWidgetSpan({ - required int userId, - required double fontSize, - required TextScaler textScaler, - StatusEmojiPosition position = StatusEmojiPosition.after, - bool neverAnimate = true, - }) { - final (double paddingStart, double paddingEnd) = switch (position) { - StatusEmojiPosition.before => (0, _spanPadding), - StatusEmojiPosition.after => (_spanPadding, 0), - }; - final size = textScaler.scale(fontSize); - return WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: UserStatusEmoji(userId: userId, size: size, - padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), - neverAnimate: neverAnimate)); - } - - @override - Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); - final emoji = store.getUserStatus(userId).emoji; - - final placeholder = SizedBox.shrink(); - if (emoji == null) return placeholder; - - final emojiDisplay = store.emojiDisplayFor( - emojiType: emoji.reactionType, - emojiCode: emoji.emojiCode, - emojiName: emoji.emojiName) - // Web doesn't seem to respect the emojiset user settings for user status. - // .resolve(store.userSettings) - ; - return switch (emojiDisplay) { - UnicodeEmojiDisplay() => Padding( - padding: padding, - child: UnicodeEmojiWidget(size: size, emojiDisplay: emojiDisplay)), - ImageEmojiDisplay() => Padding( - padding: padding, - child: ImageEmojiWidget( - size: size, - emojiDisplay: emojiDisplay, - neverAnimate: neverAnimate, - // If image emoji fails to load, show nothing. - errorBuilder: (_, _, _) => placeholder)), - // The user-status feature doesn't support a :text_emoji:-style display. - // Also, if an image emoji's URL string doesn't parse, it'll fall back to - // a :text_emoji:-style display. We show nothing for this case. - TextEmojiDisplay() => placeholder, - }; - } -} - -/// The position of the status emoji span relative to another text span. -enum StatusEmojiPosition { before, after } - // // Small helpers. // diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index dd269e03f7..a1dea0dff8 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -10,7 +10,6 @@ import 'app.dart'; import 'app_bar.dart'; import 'button.dart'; import 'color.dart'; -import 'content.dart'; import 'icons.dart'; import 'inbox.dart'; import 'inset_shadow.dart'; @@ -23,6 +22,7 @@ import 'store.dart'; import 'subscription_list.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; enum _HomePageTab { inbox, diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 7199c72a5c..bf11522036 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -14,6 +14,7 @@ import 'content.dart'; import 'dialog.dart'; import 'page.dart'; import 'store.dart'; +import 'user.dart'; /// Identifies which [LightboxHero]s should match up with each other /// to produce a hero animation. diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 3cc767f0c5..0371a29b75 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -31,6 +31,7 @@ import 'store.dart'; import 'text.dart'; import 'theme.dart'; import 'topic_list.dart'; +import 'user.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index e67b62e382..682adea91c 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -6,13 +6,13 @@ import '../model/autocomplete.dart'; import '../model/narrow.dart'; import '../model/store.dart'; import 'color.dart'; -import 'content.dart'; import 'icons.dart'; import 'message_list.dart'; import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; void showNewDmSheet(BuildContext context) { final pageContext = PageRoot.contextOf(context); diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 6576e2a0fe..9b65831b29 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -17,6 +17,7 @@ import 'remote_settings.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; class _TextStyles { static const primaryFieldText = TextStyle(fontSize: 20); diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 96ecfdbef4..f4846bf943 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -4,7 +4,6 @@ import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; -import 'content.dart'; import 'icons.dart'; import 'message_list.dart'; import 'new_dm_sheet.dart'; @@ -13,6 +12,7 @@ import 'store.dart'; import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; +import 'user.dart'; class RecentDmConversationsPageBody extends StatefulWidget { const RecentDmConversationsPageBody({super.key}); diff --git a/lib/widgets/user.dart b/lib/widgets/user.dart new file mode 100644 index 0000000000..9406580fb2 --- /dev/null +++ b/lib/widgets/user.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/avatar_url.dart'; +import '../model/binding.dart'; +import '../model/emoji.dart'; +import '../model/presence.dart'; +import 'content.dart'; +import 'emoji.dart'; +import 'icons.dart'; +import 'store.dart'; +import 'theme.dart'; + +/// A rounded square with size [size] showing a user's avatar. +class Avatar extends StatelessWidget { + const Avatar({ + super.key, + required this.userId, + required this.size, + required this.borderRadius, + this.backgroundColor, + this.showPresence = true, + this.replaceIfMuted = true, + }); + + final int userId; + final double size; + final double borderRadius; + final Color? backgroundColor; + final bool showPresence; + final bool replaceIfMuted; + + @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, replaceIfMuted: replaceIfMuted)); + } +} + +/// The appropriate avatar image for a user ID. +/// +/// If the user isn't found, gives a [SizedBox.shrink]. +/// +/// Wrap this with [AvatarShape]. +class AvatarImage extends StatelessWidget { + const AvatarImage({ + 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) { + final store = PerAccountStoreWidget.of(context); + final user = store.getUser(userId); + + if (user == null) { // TODO(log) + 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), + }; + + if (resolvedUrl == null) { + return const SizedBox.shrink(); + } + + final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl); + final physicalSize = (MediaQuery.devicePixelRatioOf(context) * size).ceil(); + + return RealmContentNetworkImage( + avatarUrl.get(physicalSize), + filterQuality: FilterQuality.medium, + fit: BoxFit.cover, + ); + } +} + +/// 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] +/// 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) { + // (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))); + } +} + +/// A user status emoji to be displayed in different parts of the app. +/// +/// Use [padding] to control the padding of status emoji from neighboring +/// widgets. +/// When there is no status emoji to be shown, the padding will be omitted too. +/// +/// Use [neverAnimate] to forcefully disable the animation for animated emojis. +/// Defaults to true. +class UserStatusEmoji extends StatelessWidget { + const UserStatusEmoji({ + super.key, + required this.userId, + required this.size, + this.padding = EdgeInsets.zero, + this.neverAnimate = true, + }); + + final int userId; + final double size; + final EdgeInsetsGeometry padding; + final bool neverAnimate; + + static const double _spanPadding = 4; + + /// Creates a [WidgetSpan] with a [UserStatusEmoji], for use in rich text; + /// before or after a text span. + /// + /// Use [position] to tell the emoji span where it is located relative to + /// another span, so that it can adjust the necessary padding from it. + static InlineSpan asWidgetSpan({ + required int userId, + required double fontSize, + required TextScaler textScaler, + StatusEmojiPosition position = StatusEmojiPosition.after, + bool neverAnimate = true, + }) { + final (double paddingStart, double paddingEnd) = switch (position) { + StatusEmojiPosition.before => (0, _spanPadding), + StatusEmojiPosition.after => (_spanPadding, 0), + }; + final size = textScaler.scale(fontSize); + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: UserStatusEmoji(userId: userId, size: size, + padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), + neverAnimate: neverAnimate)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final emoji = store.getUserStatus(userId).emoji; + + final placeholder = SizedBox.shrink(); + if (emoji == null) return placeholder; + + final emojiDisplay = store.emojiDisplayFor( + emojiType: emoji.reactionType, + emojiCode: emoji.emojiCode, + emojiName: emoji.emojiName) + // Web doesn't seem to respect the emojiset user settings for user status. + // .resolve(store.userSettings) + ; + return switch (emojiDisplay) { + UnicodeEmojiDisplay() => Padding( + padding: padding, + child: UnicodeEmojiWidget(size: size, emojiDisplay: emojiDisplay)), + ImageEmojiDisplay() => Padding( + padding: padding, + child: ImageEmojiWidget( + size: size, + emojiDisplay: emojiDisplay, + neverAnimate: neverAnimate, + // If image emoji fails to load, show nothing. + errorBuilder: (_, _, _) => placeholder)), + // The user-status feature doesn't support a :text_emoji:-style display. + // Also, if an image emoji's URL string doesn't parse, it'll fall back to + // a :text_emoji:-style display. We show nothing for this case. + TextEmojiDisplay() => placeholder, + }; + } +} + +/// The position of the status emoji span relative to another text span. +enum StatusEmojiPosition { before, after } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 3ca737dd40..631856efde 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -32,6 +32,7 @@ import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart'; import 'package:zulip/widgets/subscription_list.dart'; +import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 118db51c18..ddcfc26036 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -16,8 +16,8 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; -import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; diff --git a/test/widgets/checks.dart b/test/widgets/checks.dart index 3ea33750e1..69ff9a5f22 100644 --- a/test/widgets/checks.dart +++ b/test/widgets/checks.dart @@ -13,6 +13,7 @@ import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/profile.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/unread_count_badge.dart'; +import 'package:zulip/widgets/user.dart'; extension ChannelColorSwatchChecks on Subject { Subject get base => has((s) => s.base, 'base'); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 4995317890..0075f50b11 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -25,7 +25,6 @@ import '../model/binding.dart'; import '../model/content_test.dart'; import '../model/store_checks.dart'; import '../model/test_store.dart'; -import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; import 'checks.dart'; @@ -1284,69 +1283,6 @@ void main() { }); }); - group('AvatarImage', () { - late PerAccountStore store; - - Future actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - final user = eg.user(avatarUrl: avatarUrl); - await store.addUser(user); - - prepareBoringImageHttpClient(); - await tester.pumpWidget(GlobalStoreWidget( - child: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: AvatarImage(userId: user.userId, size: size ?? 30)))); - await tester.pump(); - await tester.pump(); - tester.widget(find.byType(AvatarImage)); - final widgets = tester.widgetList( - find.byType(RealmContentNetworkImage)); - return widgets.firstOrNull?.src; - } - - testWidgets('smoke with absolute URL', (tester) async { - const avatarUrl = 'https://example/avatar.png'; - check(await actualUrl(tester, avatarUrl)).isNotNull() - .asString.equals(avatarUrl); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('smoke with relative URL', (tester) async { - const avatarUrl = '/avatar.png'; - check(await actualUrl(tester, avatarUrl)) - .equals(store.tryResolveUrl(avatarUrl)!); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('absolute URL, larger size', (tester) async { - tester.view.devicePixelRatio = 2.5; - addTearDown(tester.view.resetDevicePixelRatio); - - const avatarUrl = 'https://example/avatar.png'; - check(await actualUrl(tester, avatarUrl, 50)).isNotNull() - .asString.equals(avatarUrl.replaceAll('.png', '-medium.png')); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('relative URL, larger size', (tester) async { - tester.view.devicePixelRatio = 2.5; - addTearDown(tester.view.resetDevicePixelRatio); - - const avatarUrl = '/avatar.png'; - check(await actualUrl(tester, avatarUrl, 50)) - .equals(store.tryResolveUrl('/avatar-medium.png')!); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('smoke with invalid URL', (tester) async { - const avatarUrl = '::not a URL::'; - check(await actualUrl(tester, avatarUrl)).isNull(); - debugNetworkImageHttpClientProvider = null; - }); - }); - group('MessageTable', () { testFontWeight('bold column header label', // | a | b | c | d | diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 365e550bd9..dbeb06a3fd 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -17,6 +17,7 @@ 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 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index b491777e8e..a7c4b14dbf 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -34,6 +34,7 @@ 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 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index 72a0ecab18..ae77e9643f 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -7,11 +7,11 @@ import 'package:zulip/basic.dart'; import 'package:zulip/model/store.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 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index e03b665cda..271062aedb 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -19,6 +19,7 @@ import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/remote_settings.dart'; import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 4f524f33f4..a947420dad 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -8,13 +8,13 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; -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'; +import 'package:zulip/widgets/user.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; diff --git a/test/widgets/user_test.dart b/test/widgets/user_test.dart new file mode 100644 index 0000000000..5078da0497 --- /dev/null +++ b/test/widgets/user_test.dart @@ -0,0 +1,82 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/user.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import '../test_images.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('AvatarImage', () { + late PerAccountStore store; + + Future actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final user = eg.user(avatarUrl: avatarUrl); + await store.addUser(user); + + prepareBoringImageHttpClient(); + await tester.pumpWidget(GlobalStoreWidget( + child: PerAccountStoreWidget(accountId: eg.selfAccount.id, + child: AvatarImage(userId: user.userId, size: size ?? 30)))); + await tester.pump(); + await tester.pump(); + tester.widget(find.byType(AvatarImage)); + final widgets = tester.widgetList( + find.byType(RealmContentNetworkImage)); + return widgets.firstOrNull?.src; + } + + testWidgets('smoke with absolute URL', (tester) async { + const avatarUrl = 'https://example/avatar.png'; + check(await actualUrl(tester, avatarUrl)).isNotNull() + .asString.equals(avatarUrl); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke with relative URL', (tester) async { + const avatarUrl = '/avatar.png'; + check(await actualUrl(tester, avatarUrl)) + .equals(store.tryResolveUrl(avatarUrl)!); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('absolute URL, larger size', (tester) async { + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.resetDevicePixelRatio); + + const avatarUrl = 'https://example/avatar.png'; + check(await actualUrl(tester, avatarUrl, 50)).isNotNull() + .asString.equals(avatarUrl.replaceAll('.png', '-medium.png')); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('relative URL, larger size', (tester) async { + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.resetDevicePixelRatio); + + const avatarUrl = '/avatar.png'; + check(await actualUrl(tester, avatarUrl, 50)) + .equals(store.tryResolveUrl('/avatar-medium.png')!); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke with invalid URL', (tester) async { + const avatarUrl = '::not a URL::'; + check(await actualUrl(tester, avatarUrl)).isNull(); + debugNetworkImageHttpClientProvider = null; + }); + }); +} From 5d5446816554ff0b700b347422e22f848fbfc5f7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 12:43:38 -0700 Subject: [PATCH 361/423] settings test: Check theme-setting radio buttons by checking semantics This tests the observable behavior more directly. RadioListTile.checked has been deprecated (#1545), so we'd like this test to stop relying on that implementation detail. --- test/widgets/settings_test.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index 96fd62feeb..da02021ad7 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/settings.dart'; import 'package:zulip/widgets/settings.dart'; @@ -39,9 +40,12 @@ void main() { ThemeSetting.dark => 'Dark', }; for (final title in ['System', 'Light', 'Dark']) { - check(tester.widget>( - findRadioListTileWithTitle(title))) - .checked.equals(title == expectedCheckedTitle); + final expectedIsChecked = title == expectedCheckedTitle; + check(tester.semantics.find(findRadioListTileWithTitle(title))) + .containsSemantics( + label: title, + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, isChecked: expectedIsChecked); } check(testBinding.globalStore) .settings.themeSetting.equals(expectedThemeSetting); From 01655d6f842e2add8133a3d1dcba89ce6cfdbe80 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 17 Jun 2025 11:38:07 -0700 Subject: [PATCH 362/423] settings test [nfc]: Pull out checkRadioButtonAppearsChecked helper --- test/widgets/settings_test.dart | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index da02021ad7..40cd1eede6 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -26,11 +26,19 @@ void main() { await tester.pump(); } - group('ThemeSetting', () { - Finder findRadioListTileWithTitle(String title) => find.ancestor( - of: find.text(title), - matching: find.byType(RadioListTile)); + Finder findRadioListTileWithTitle(String title) => find.ancestor( + of: find.text(title), + matching: find.byType(RadioListTile)); + + void checkRadioButtonAppearsChecked(WidgetTester tester, String title, bool expectedIsChecked) { + check(tester.semantics.find(findRadioListTileWithTitle(title))) + .containsSemantics( + label: title, + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, isChecked: expectedIsChecked); + } + group('ThemeSetting', () { void checkThemeSetting(WidgetTester tester, { required ThemeSetting? expectedThemeSetting, }) { @@ -40,12 +48,7 @@ void main() { ThemeSetting.dark => 'Dark', }; for (final title in ['System', 'Light', 'Dark']) { - final expectedIsChecked = title == expectedCheckedTitle; - check(tester.semantics.find(findRadioListTileWithTitle(title))) - .containsSemantics( - label: title, - isInMutuallyExclusiveGroup: true, - hasCheckedState: true, isChecked: expectedIsChecked); + checkRadioButtonAppearsChecked(tester, title, title == expectedCheckedTitle); } check(testBinding.globalStore) .settings.themeSetting.equals(expectedThemeSetting); @@ -60,13 +63,13 @@ void main() { check(Theme.of(element)).brightness.equals(Brightness.light); checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.light); - await tester.tap(findRadioListTileWithTitle('Dark')); + await tester.tap(findRadioListTileWithTitle('Dark')); await tester.pump(); await tester.pump(Duration(milliseconds: 250)); // wait for transition check(Theme.of(element)).brightness.equals(Brightness.dark); checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.dark); - await tester.tap(findRadioListTileWithTitle('System')); + await tester.tap(findRadioListTileWithTitle('System')); await tester.pump(); await tester.pump(Duration(milliseconds: 250)); // wait for transition check(Theme.of(element)).brightness.equals(Brightness.light); From d9aafc8c3d07337d61b1a8cfce27f57ab0702073 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 17 Jun 2025 12:25:55 -0700 Subject: [PATCH 363/423] settings test: Add widget tests for latest settings "Open message feeds at" and "Mark messages as read on scroll". Related: #1571 Related: #1583 --- test/model/store_checks.dart | 2 + test/widgets/settings_test.dart | 165 +++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index c157ac2190..6321fa057e 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -32,6 +32,8 @@ extension GlobalSettingsStoreChecks on Subject { Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); Subject get effectiveBrowserPreference => has((x) => x.effectiveBrowserPreference, 'effectiveBrowserPreference'); Subject getUrlLaunchMode(Uri url) => has((x) => x.getUrlLaunchMode(url), 'getUrlLaunchMode'); + Subject get visitFirstUnread => has((x) => x.visitFirstUnread, 'visitFirstUnread'); + Subject get markReadOnScroll => has((x) => x.markReadOnScroll, 'markReadOnScroll'); Subject getBool(BoolGlobalSetting setting) => has((x) => x.getBool(setting), 'getBool(${setting.name}'); } diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index 40cd1eede6..e889fa0d3d 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -4,36 +4,62 @@ import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/settings.dart'; +import 'package:zulip/widgets/store.dart'; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/store_checks.dart'; import '../example_data.dart' as eg; +import '../test_navigation.dart'; +import 'checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + late TestNavigatorObserver testNavObserver; + late Route? lastPushedRoute; + late Route? lastPoppedRoute; + Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + testNavObserver = TestNavigatorObserver() + ..onPushed = ((route, _) => lastPushedRoute = route) + ..onPopped = ((route, _) => lastPoppedRoute = route); + lastPushedRoute = null; + lastPoppedRoute = null; + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, + navigatorObservers: [testNavObserver], child: SettingsPage())); await tester.pump(); await tester.pump(); } + void checkTileOnSettingsPage(WidgetTester tester, { + required String expectedTitle, + required String expectedSubtitle, + }) { + check(find.descendant(of: find.widgetWithText(ListTile, expectedTitle), + matching: find.text(expectedSubtitle))).findsOne(); + } + Finder findRadioListTileWithTitle(String title) => find.ancestor( of: find.text(title), matching: find.byType(RadioListTile)); - void checkRadioButtonAppearsChecked(WidgetTester tester, String title, bool expectedIsChecked) { + void checkRadioButtonAppearsChecked(WidgetTester tester, + String title, bool expectedIsChecked, {String? subtitle}) { check(tester.semantics.find(findRadioListTileWithTitle(title))) .containsSemantics( - label: title, + label: subtitle == null + ? title + : '$title\n$subtitle', isInMutuallyExclusiveGroup: true, hasCheckedState: true, isChecked: expectedIsChecked); } @@ -134,7 +160,140 @@ void main() { }, variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); }); - // TODO(#1571): test visitFirstUnread setting UI + group('VisitFirstUnreadSetting', () { + String settingTitle(VisitFirstUnreadSetting setting) => switch (setting) { + VisitFirstUnreadSetting.always => 'First unread message', + VisitFirstUnreadSetting.conversations => 'First unread message in conversation views, newest message elsewhere', + VisitFirstUnreadSetting.never => 'Newest message', + }; + + void checkPage(WidgetTester tester, { + required VisitFirstUnreadSetting expectedSetting, + }) { + for (final setting in VisitFirstUnreadSetting.values) { + final thisSettingTitle = settingTitle(setting); + checkRadioButtonAppearsChecked(tester, + thisSettingTitle, setting == expectedSetting); + } + } + + testWidgets('smoke', (tester) async { + await prepare(tester); + + // "conversations" is the default, and it appears in the SettingsPage + // (as the setting tile's subtitle) + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .visitFirstUnread.equals(VisitFirstUnreadSetting.conversations); + checkTileOnSettingsPage(tester, + expectedTitle: 'Open message feeds at', + expectedSubtitle: settingTitle(VisitFirstUnreadSetting.conversations)); + + await tester.tap(find.text('Open message feeds at')); + await tester.pump(); + check(lastPushedRoute).isA() + .page.isA(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.always))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.always); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.conversations))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.never))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.never); + + await tester.tap(find.backButton()); + check(lastPoppedRoute).isA() + .page.isA(); + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .visitFirstUnread.equals(VisitFirstUnreadSetting.never); + + checkTileOnSettingsPage(tester, + expectedTitle: 'Open message feeds at', + expectedSubtitle: settingTitle(VisitFirstUnreadSetting.never)); + }); + }); + + group('MarkReadOnScrollSetting', () { + String settingTitle(MarkReadOnScrollSetting setting) => switch (setting) { + MarkReadOnScrollSetting.always => 'Always', + MarkReadOnScrollSetting.conversations => 'Only in conversation views', + MarkReadOnScrollSetting.never => 'Never', + }; + + String? settingSubtitle(MarkReadOnScrollSetting setting) => switch (setting) { + MarkReadOnScrollSetting.always => null, + MarkReadOnScrollSetting.conversations => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.', + MarkReadOnScrollSetting.never => null, + }; + + void checkPage(WidgetTester tester, { + required MarkReadOnScrollSetting expectedSetting, + }) { + for (final setting in MarkReadOnScrollSetting.values) { + final thisSettingTitle = settingTitle(setting); + checkRadioButtonAppearsChecked(tester, + thisSettingTitle, + setting == expectedSetting, + subtitle: settingSubtitle(setting)); + } + } + + testWidgets('smoke', (tester) async { + await prepare(tester); + + // "conversations" is the default, and it appears in the SettingsPage + // (as the setting tile's subtitle) + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .markReadOnScroll.equals(MarkReadOnScrollSetting.conversations); + checkTileOnSettingsPage(tester, + expectedTitle: 'Mark messages as read on scroll', + expectedSubtitle: settingTitle(MarkReadOnScrollSetting.conversations)); + + await tester.tap(find.text('Mark messages as read on scroll')); + await tester.pump(); + check(lastPushedRoute).isA() + .page.isA(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.always))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.always); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.conversations))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.never))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.never); + + await tester.tap(find.byType(BackButton)); + check(lastPoppedRoute).isA() + .page.isA(); + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .markReadOnScroll.equals(MarkReadOnScrollSetting.never); + + checkTileOnSettingsPage(tester, + expectedTitle: 'Mark messages as read on scroll', + expectedSubtitle: settingTitle(MarkReadOnScrollSetting.never)); + }); + }); // TODO maybe test GlobalSettingType.experimentalFeatureFlag settings // Or maybe not; after all, it's a developer-facing feature, so From 193ee915d07bddc8c773db75ae1e390ecf3d757e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 3 Jun 2025 12:44:32 -0700 Subject: [PATCH 364/423] settings: Migrate to new RadioGroup API for radio buttons This migration is verified by the tests that we touched in the previous commits; they ensure that the buttons' checked state still updates visibly. Fixes: #1545 --- lib/widgets/settings.dart | 86 ++++++++++++++++++--------------------- test/flutter_checks.dart | 6 --- 2 files changed, 40 insertions(+), 52 deletions(-) diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 394415a8be..5995cdcbfe 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -46,21 +46,19 @@ class _ThemeSetting extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final globalSettings = GlobalStoreWidget.settingsOf(context); - return Column( - children: [ - ListTile(title: Text(zulipLocalizations.themeSettingTitle)), - for (final themeSettingOption in [null, ...ThemeSetting.values]) - RadioListTile.adaptive( - title: Text(ThemeSetting.displayName( - 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)), - ]); + return RadioGroup( + groupValue: globalSettings.themeSetting, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column( + children: [ + ListTile(title: Text(zulipLocalizations.themeSettingTitle)), + for (final themeSettingOption in [null, ...ThemeSetting.values]) + RadioListTile.adaptive( + title: Text(ThemeSetting.displayName( + themeSetting: themeSettingOption, + zulipLocalizations: zulipLocalizations)), + value: themeSettingOption), + ])); } } @@ -135,19 +133,17 @@ class VisitFirstUnreadSettingPage extends StatelessWidget { 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)), - ])); + body: RadioGroup( + groupValue: globalSettings.visitFirstUnread, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column(children: [ + ListTile(title: Text(zulipLocalizations.initialAnchorSettingDescription)), + for (final value in VisitFirstUnreadSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + value: value), + ]))); } } @@ -210,24 +206,22 @@ class MarkReadOnScrollSettingPage extends StatelessWidget { 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)), - ])); + body: RadioGroup( + groupValue: globalSettings.markReadOnScroll, + onChanged: (newValue) => _handleChange(context, newValue), + child: 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), + ]))); } } diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 1bafd6636f..c6e31bb1fa 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -249,9 +249,3 @@ extension IconButtonChecks on Subject { extension SwitchListTileChecks on Subject { Subject get value => has((x) => x.value, 'value'); } - -extension RadioListTileChecks on Subject> { - // TODO(#1545) stop using the deprecated member - // ignore: deprecated_member_use - Subject get checked => has((x) => x.checked, 'checked'); -} From aaa1e664c2f9e54c3fbd946c524dd88ff4a22f78 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 21:07:27 -0700 Subject: [PATCH 365/423] message [nfc]: Simplify this copy of realmEmptyTopicDisplayName; explain a bit --- lib/model/message.dart | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 0812c49112..63a1044540 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -109,16 +109,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes // 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'; - } + // This copy of the realm setting is here to bypass the feature-level check + // on the usual getter for it. See the one access site of this field. final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting @override From d4afa40a566e85962590b3e60f9fa3e32a2c71c7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 21:07:38 -0700 Subject: [PATCH 366/423] message [nfc]: Use getter for realmEmptyTopicDisplayName rather than pass down params This is a bit simpler in itself, and also closer to what we'll want with the help of a substore. --- lib/model/message.dart | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 63a1044540..a7a6983c4d 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -110,7 +110,10 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes messages = {}; // This copy of the realm setting is here to bypass the feature-level check - // on the usual getter for it. See the one access site of this field. + // on the usual getter for it. See discussion: + // https://github.com/zulip/zulip-flutter/pull/1472#discussion_r2099069276 + // TODO move [TopicName.processLikeServer] to a substore, eliminating this + @override final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting @override @@ -250,11 +253,7 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes 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); + return _outboxSendMessage(destination: destination, content: content); } @override @@ -773,6 +772,8 @@ class DmOutboxMessage extends OutboxMessage { /// Manages the outbox messages portion of [MessageStore]. mixin _OutboxMessageStore on PerAccountStoreBase { + String? get _realmEmptyTopicDisplayName; + late final UnmodifiableMapView outboxMessages = UnmodifiableMapView(_outboxMessages); final Map _outboxMessages = {}; @@ -848,7 +849,6 @@ mixin _OutboxMessageStore on PerAccountStoreBase { Future _outboxSendMessage({ required MessageDestination destination, required String content, - required String? realmEmptyTopicDisplayName, }) async { assert(!_disposed); final localMessageId = _nextLocalMessageId++; @@ -858,8 +858,7 @@ mixin _OutboxMessageStore on PerAccountStoreBase { StreamDestination(:final streamId, :final topic) => StreamConversation( streamId, - _processTopicLikeServer( - topic, realmEmptyTopicDisplayName: realmEmptyTopicDisplayName), + _processTopicLikeServer(topic), displayRecipient: null), DmDestination(:final userIds) => DmConversation(allRecipientIds: userIds), }; @@ -918,9 +917,7 @@ mixin _OutboxMessageStore on PerAccountStoreBase { } } - TopicName _processTopicLikeServer(TopicName topic, { - required String? realmEmptyTopicDisplayName, - }) { + TopicName _processTopicLikeServer(TopicName topic) { return topic.processLikeServer( // Processing this just once on creating the outbox message // allows an uncommon bug, because either of these values can change. @@ -936,7 +933,7 @@ mixin _OutboxMessageStore on PerAccountStoreBase { // 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); + realmEmptyTopicDisplayName: _realmEmptyTopicDisplayName); } void _handleOutboxDebounce(int localMessageId) { From c5e1d23f9adcea21597ec3d5d0309a9199a9b212 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 22:21:43 -0700 Subject: [PATCH 367/423] realm [nfc]: Introduce RealmStore, initially vacuous --- lib/model/realm.dart | 18 ++++++++++++++++++ lib/model/store.dart | 7 +++++++ 2 files changed, 25 insertions(+) create mode 100644 lib/model/realm.dart diff --git a/lib/model/realm.dart b/lib/model/realm.dart new file mode 100644 index 0000000000..d485b67331 --- /dev/null +++ b/lib/model/realm.dart @@ -0,0 +1,18 @@ +import '../api/model/initial_snapshot.dart'; +import 'store.dart'; + +/// The portion of [PerAccountStore] for realm settings, server settings, +/// and similar data about the whole realm or server. +/// +/// See also: +/// * [RealmStoreImpl] for the implementation of this that does the work. +mixin RealmStore { +} + +/// The implementation of [RealmStore] that does the work. +class RealmStoreImpl extends PerAccountStoreBase with RealmStore { + RealmStoreImpl({ + required super.core, + required InitialSnapshot initialSnapshot, + }); +} diff --git a/lib/model/store.dart b/lib/model/store.dart index 7b48147f12..aea21ca11f 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -27,6 +27,7 @@ import 'localizations.dart'; import 'message.dart'; import 'message_list.dart'; import 'presence.dart'; +import 'realm.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; import 'channel.dart'; @@ -437,6 +438,7 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, UserGroupStore, ProxyUserGroupStore, + RealmStore, EmojiStore, SavedSnippetStore, UserStore, @@ -498,6 +500,7 @@ class PerAccountStore extends PerAccountStoreBase with realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts, customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields), emailAddressVisibility: initialSnapshot.emailAddressVisibility, + realm: RealmStoreImpl(core: core, initialSnapshot: initialSnapshot), emoji: EmojiStoreImpl( core: core, allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, @@ -548,6 +551,7 @@ class PerAccountStore extends PerAccountStoreBase with required this.realmDefaultExternalAccounts, required this.customProfileFields, required this.emailAddressVisibility, + required RealmStoreImpl realm, required EmojiStoreImpl emoji, required this.userSettings, required SavedSnippetStoreImpl savedSnippets, @@ -562,6 +566,7 @@ class PerAccountStore extends PerAccountStoreBase with required this.recentSenders, }) : _groups = groups, _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, + _realm = realm, _emoji = emoji, _savedSnippets = savedSnippets, _users = users, @@ -630,6 +635,8 @@ class PerAccountStore extends PerAccountStoreBase with /// For docs, please see [InitialSnapshot.emailAddressVisibility]. final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting + final RealmStoreImpl _realm; // ignore: unused_field // TODO + //////////////////////////////// // The realm's repertoire of available emoji. From a5789477d4ceb6bdaa9143d2d91743f0b0a7693a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 13:55:54 -0700 Subject: [PATCH 368/423] profile test: Have displayInProfileSummary default to false, not true This is the default when someone actually creates a custom profile field, so the more realistic default for test data. --- test/widgets/profile_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 271062aedb..12e74bd1cb 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -84,7 +84,7 @@ CustomProfileField mkCustomProfileField( name: 'field$id', hint: 'hint$id', fieldData: fieldData ?? '', - displayInProfileSummary: displayInProfileSummary ?? true, + displayInProfileSummary: displayInProfileSummary ?? false, ); } From 221ba81f2c41414538dd3a7d811eebe66de06c1b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 13:54:53 -0700 Subject: [PATCH 369/423] test [nfc]: Move eg.customProfileField out from one test file --- test/example_data.dart | 18 ++++++++++ test/widgets/profile_test.dart | 60 ++++++++++++---------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index 06d6d130c0..5f0a79c4fb 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -131,6 +131,24 @@ GetServerSettingsResult serverSettings({ ); } +CustomProfileField customProfileField( + int id, + CustomProfileFieldType type, { + int? order, + bool? displayInProfileSummary, + String? fieldData, +}) { + return CustomProfileField( + id: id, + type: type, + order: order ?? id, + name: 'field$id', + hint: 'hint$id', + fieldData: fieldData ?? '', + displayInProfileSummary: displayInProfileSummary ?? false, + ); +} + ServerEmojiData _immutableServerEmojiData({ required Map> codeToNames}) { return ServerEmojiData( diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 12e74bd1cb..f1aaebd807 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -70,24 +70,6 @@ Future setupPage(WidgetTester tester, { await tester.pumpAndSettle(); } -CustomProfileField mkCustomProfileField( - int id, - CustomProfileFieldType type, { - int? order, - bool? displayInProfileSummary, - String? fieldData, -}) { - return CustomProfileField( - id: id, - type: type, - order: order ?? id, - name: 'field$id', - hint: 'hint$id', - fieldData: fieldData ?? '', - displayInProfileSummary: displayInProfileSummary ?? false, - ); -} - void main() { TestZulipBinding.ensureInitialized(); @@ -153,16 +135,16 @@ void main() { ], pageUserId: 1, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.shortText), - mkCustomProfileField(1, CustomProfileFieldType.longText), - mkCustomProfileField(2, CustomProfileFieldType.choice, + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.longText), + eg.customProfileField(2, CustomProfileFieldType.choice, fieldData: '{"x": {"text": "choiceValue", "order": "1"}}'), - mkCustomProfileField(3, CustomProfileFieldType.date), - mkCustomProfileField(4, CustomProfileFieldType.link), - mkCustomProfileField(5, CustomProfileFieldType.user), - mkCustomProfileField(6, CustomProfileFieldType.externalAccount, + eg.customProfileField(3, CustomProfileFieldType.date), + eg.customProfileField(4, CustomProfileFieldType.link), + eg.customProfileField(5, CustomProfileFieldType.user), + eg.customProfileField(6, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}'), - mkCustomProfileField(7, CustomProfileFieldType.pronouns), + eg.customProfileField(7, CustomProfileFieldType.pronouns), ], realmDefaultExternalAccounts: { 'external1': RealmDefaultExternalAccount( name: 'external1', @@ -207,7 +189,7 @@ void main() { await setupPage(tester, users: [user], pageUserId: user.userId, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.link)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.link)], ); await tester.tap(find.text(testUrl)); @@ -226,7 +208,7 @@ void main() { users: [user], pageUserId: user.userId, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.externalAccount, + eg.customProfileField(0, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}') ], realmDefaultExternalAccounts: { @@ -258,7 +240,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], navigatorObserver: testNavObserver, ); @@ -279,7 +261,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], ); final textFinder = find.text('(unknown user)'); @@ -310,7 +292,7 @@ void main() { users: users, mutedUserIds: [2], pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)]); + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)]); check(find.text('Muted user')).findsOne(); check(mutedAvatarFinder(2)).findsOne(); @@ -335,7 +317,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], ); final avatars = tester.widgetList(find.byType(Avatar)); @@ -358,16 +340,16 @@ void main() { await setupPage(tester, users: [user, user2], pageUserId: user.userId, customProfileFields: [ - mkCustomProfileField(0, CustomProfileFieldType.shortText), - mkCustomProfileField(1, CustomProfileFieldType.longText), - mkCustomProfileField(2, CustomProfileFieldType.choice, + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.longText), + eg.customProfileField(2, CustomProfileFieldType.choice, fieldData: '{"x": {"text": "$longString", "order": "1"}}'), // no [CustomProfileFieldType.date] because those can't be made long - mkCustomProfileField(3, CustomProfileFieldType.link), - mkCustomProfileField(4, CustomProfileFieldType.user), - mkCustomProfileField(5, CustomProfileFieldType.externalAccount, + eg.customProfileField(3, CustomProfileFieldType.link), + eg.customProfileField(4, CustomProfileFieldType.user), + eg.customProfileField(5, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}'), - mkCustomProfileField(6, CustomProfileFieldType.pronouns), + eg.customProfileField(6, CustomProfileFieldType.pronouns), ], realmDefaultExternalAccounts: { 'external1': RealmDefaultExternalAccount( name: 'external1', From 6b1f9af5efc69ea2f91dd6e8c1c1ae6eb1b83498 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 14:04:28 -0700 Subject: [PATCH 370/423] store test: Test handling of customProfileFields --- test/model/store_test.dart | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 5ce42626cf..21d4ef2dc7 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -563,6 +563,48 @@ void main() { }); }); + group('PerAccountStore.customProfileFields', () { + test('update clobbers old list', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + customProfileFields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.shortText), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 1]); + + await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(2, CustomProfileFieldType.shortText), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 2]); + }); + + test('sorts by displayInProfile', () async { + // Sorts both the data from the initial snapshot… + final store = eg.store(initialSnapshot: eg.initialSnapshot( + customProfileFields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(1, CustomProfileFieldType.shortText, + displayInProfileSummary: true), + eg.customProfileField(2, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([1, 0, 2]); + + // … and from an event. + await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(1, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(2, CustomProfileFieldType.shortText, + displayInProfileSummary: true), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([2, 0, 1]); + }); + }); + group('PerAccountStore.handleEvent', () { // Mostly this method just dispatches to ChannelStore and MessageStore etc., // and so its tests generally live in the test files for those From 5efff5828271f53ff104e23afdf790307943b8a9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 22:33:16 -0700 Subject: [PATCH 371/423] realm [nfc]: Move data here from PerAccountStore --- lib/model/realm.dart | 96 ++++++++++++++++++++++++++++++++++- lib/model/store.dart | 100 +++++++++++-------------------------- test/model/realm_test.dart | 50 +++++++++++++++++++ test/model/store_test.dart | 42 ---------------- 4 files changed, 173 insertions(+), 115 deletions(-) create mode 100644 test/model/realm_test.dart diff --git a/lib/model/realm.dart b/lib/model/realm.dart index d485b67331..3f0bf05f55 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -1,4 +1,6 @@ +import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; +import '../api/model/model.dart'; import 'store.dart'; /// The portion of [PerAccountStore] for realm settings, server settings, @@ -7,6 +9,29 @@ import 'store.dart'; /// See also: /// * [RealmStoreImpl] for the implementation of this that does the work. mixin RealmStore { + int get serverPresencePingIntervalSeconds; + int get serverPresenceOfflineThresholdSeconds; + + RealmWildcardMentionPolicy get realmWildcardMentionPolicy; + bool get realmMandatoryTopics; + /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. + int get realmWaitingPeriodThreshold; + bool get realmAllowMessageEditing; + int? get realmMessageContentEditLimitSeconds; + bool get realmPresenceDisabled; + int get maxFileUploadSizeMib; + + /// 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; + + Map get realmDefaultExternalAccounts; + List get customProfileFields; + /// For docs, please see [InitialSnapshot.emailAddressVisibility]. + EmailAddressVisibility? get emailAddressVisibility; } /// The implementation of [RealmStore] that does the work. @@ -14,5 +39,74 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { RealmStoreImpl({ required super.core, required InitialSnapshot initialSnapshot, - }); + }) : + 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, + realmMessageContentEditLimitSeconds = initialSnapshot.realmMessageContentEditLimitSeconds, + realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, + customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields), + emailAddressVisibility = initialSnapshot.emailAddressVisibility; + + @override + final int serverPresencePingIntervalSeconds; + @override + final int serverPresenceOfflineThresholdSeconds; + + @override + final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting + @override + final bool realmMandatoryTopics; // TODO(#668): update this realm setting + @override + final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting + @override + final bool realmAllowMessageEditing; // TODO(#668): update this realm setting + @override + final int? realmMessageContentEditLimitSeconds; // TODO(#668): update this realm setting + @override + final bool realmPresenceDisabled; // TODO(#668): update this realm setting + @override + final int maxFileUploadSizeMib; // No event for this. + + @override + String get realmEmptyTopicDisplayName { + assert(zulipFeatureLevel >= 334); // TODO(server-10) + assert(_realmEmptyTopicDisplayName != null); // TODO(log) + return _realmEmptyTopicDisplayName ?? 'general chat'; + } + final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting + + @override + final Map realmDefaultExternalAccounts; + + @override + List customProfileFields; + + 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 + // the order they appear in the array (`custom_profile_fields` in the + // API; our `realmFields` array here.) See chat thread: + // https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1382982 + // + // We go on to put at the start of the list any fields that are marked for + // displaying in the "profile summary". (Possibly they should be at the + // start of the list in the first place, but make sure just in case.) + final displayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary == true); + final nonDisplayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary != true); + return displayFields.followedBy(nonDisplayFields).toList(); + } + + @override + final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting + + void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) { + customProfileFields = _sortCustomProfileFields(event.fields); + } } diff --git a/lib/model/store.dart b/lib/model/store.dart index aea21ca11f..508700713c 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -487,19 +487,6 @@ class PerAccountStore extends PerAccountStoreBase with core: core, groups: UserGroupStoreImpl(core: core, groups: initialSnapshot.realmUserGroups), - 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, - realmMessageContentEditLimitSeconds: initialSnapshot.realmMessageContentEditLimitSeconds, - realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts, - customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields), - emailAddressVisibility: initialSnapshot.emailAddressVisibility, realm: RealmStoreImpl(core: core, initialSnapshot: initialSnapshot), emoji: EmojiStoreImpl( core: core, allRealmEmoji: initialSnapshot.realmEmoji), @@ -538,19 +525,6 @@ class PerAccountStore extends PerAccountStoreBase with PerAccountStore._({ required super.core, required UserGroupStoreImpl groups, - 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, - required this.realmMessageContentEditLimitSeconds, - required this.realmDefaultExternalAccounts, - required this.customProfileFields, - required this.emailAddressVisibility, required RealmStoreImpl realm, required EmojiStoreImpl emoji, required this.userSettings, @@ -565,7 +539,6 @@ class PerAccountStore extends PerAccountStoreBase with required this.recentDmConversationsView, required this.recentSenders, }) : _groups = groups, - _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, _realm = realm, _emoji = emoji, _savedSnippets = savedSnippets, @@ -606,36 +579,34 @@ class PerAccountStore extends PerAccountStoreBase with UserGroupStore get userGroupStore => _groups; final UserGroupStoreImpl _groups; - 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. - /// - /// 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 - - final Map realmDefaultExternalAccounts; - List customProfileFields; - /// For docs, please see [InitialSnapshot.emailAddressVisibility]. - final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting + @override + int get serverPresencePingIntervalSeconds => _realm.serverPresencePingIntervalSeconds; + @override + int get serverPresenceOfflineThresholdSeconds => _realm.serverPresenceOfflineThresholdSeconds; + @override + RealmWildcardMentionPolicy get realmWildcardMentionPolicy => _realm.realmWildcardMentionPolicy; + @override + bool get realmMandatoryTopics => _realm.realmMandatoryTopics; + @override + int get realmWaitingPeriodThreshold => _realm.realmWaitingPeriodThreshold; + @override + bool get realmAllowMessageEditing => _realm.realmAllowMessageEditing; + @override + int? get realmMessageContentEditLimitSeconds => _realm.realmMessageContentEditLimitSeconds; + @override + bool get realmPresenceDisabled => _realm.realmPresenceDisabled; + @override + int get maxFileUploadSizeMib => _realm.maxFileUploadSizeMib; + @override + String get realmEmptyTopicDisplayName => _realm.realmEmptyTopicDisplayName; + @override + Map get realmDefaultExternalAccounts => _realm.realmDefaultExternalAccounts; + @override + List get customProfileFields => _realm.customProfileFields; + @override + EmailAddressVisibility? get emailAddressVisibility => _realm.emailAddressVisibility; - final RealmStoreImpl _realm; // ignore: unused_field // TODO + final RealmStoreImpl _realm; //////////////////////////////// // The realm's repertoire of available emoji. @@ -930,7 +901,7 @@ class PerAccountStore extends PerAccountStoreBase with case CustomProfileFieldsEvent(): assert(debugLog("server event: custom_profile_fields")); - customProfileFields = _sortCustomProfileFields(event.fields); + _realm.handleCustomProfileFieldsEvent(event); notifyListeners(); case UserGroupEvent(): @@ -1043,21 +1014,6 @@ class PerAccountStore extends PerAccountStoreBase with } } - 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 - // the order they appear in the array (`custom_profile_fields` in the - // API; our `realmFields` array here.) See chat thread: - // https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1382982 - // - // We go on to put at the start of the list any fields that are marked for - // displaying in the "profile summary". (Possibly they should be at the - // start of the list in the first place, but make sure just in case.) - final displayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary == true); - final nonDisplayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary != true); - return displayFields.followedBy(nonDisplayFields).toList(); - } - @override String toString() => '${objectRuntimeType(this, 'PerAccountStore')}#${shortHash(this)}'; } diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart new file mode 100644 index 0000000000..7dfe240686 --- /dev/null +++ b/test/model/realm_test.dart @@ -0,0 +1,50 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; + +import '../example_data.dart' as eg; + +void main() { + group('customProfileFields', () { + test('update clobbers old list', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + customProfileFields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(1, CustomProfileFieldType.shortText), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 1]); + + await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText), + eg.customProfileField(2, CustomProfileFieldType.shortText), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 2]); + }); + + test('sorts by displayInProfile', () async { + // Sorts both the data from the initial snapshot… + final store = eg.store(initialSnapshot: eg.initialSnapshot( + customProfileFields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(1, CustomProfileFieldType.shortText, + displayInProfileSummary: true), + eg.customProfileField(2, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([1, 0, 2]); + + // … and from an event. + await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ + eg.customProfileField(0, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(1, CustomProfileFieldType.shortText, + displayInProfileSummary: false), + eg.customProfileField(2, CustomProfileFieldType.shortText, + displayInProfileSummary: true), + ])); + check(store.customProfileFields.map((f) => f.id)).deepEquals([2, 0, 1]); + }); + }); +} diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 21d4ef2dc7..5ce42626cf 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -563,48 +563,6 @@ void main() { }); }); - group('PerAccountStore.customProfileFields', () { - test('update clobbers old list', () async { - final store = eg.store(initialSnapshot: eg.initialSnapshot( - customProfileFields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText), - eg.customProfileField(1, CustomProfileFieldType.shortText), - ])); - check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 1]); - - await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText), - eg.customProfileField(2, CustomProfileFieldType.shortText), - ])); - check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 2]); - }); - - test('sorts by displayInProfile', () async { - // Sorts both the data from the initial snapshot… - final store = eg.store(initialSnapshot: eg.initialSnapshot( - customProfileFields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText, - displayInProfileSummary: false), - eg.customProfileField(1, CustomProfileFieldType.shortText, - displayInProfileSummary: true), - eg.customProfileField(2, CustomProfileFieldType.shortText, - displayInProfileSummary: false), - ])); - check(store.customProfileFields.map((f) => f.id)).deepEquals([1, 0, 2]); - - // … and from an event. - await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText, - displayInProfileSummary: false), - eg.customProfileField(1, CustomProfileFieldType.shortText, - displayInProfileSummary: false), - eg.customProfileField(2, CustomProfileFieldType.shortText, - displayInProfileSummary: true), - ])); - check(store.customProfileFields.map((f) => f.id)).deepEquals([2, 0, 1]); - }); - }); - group('PerAccountStore.handleEvent', () { // Mostly this method just dispatches to ChannelStore and MessageStore etc., // and so its tests generally live in the test files for those From 3098213281679fe246e9ba8382d4a1d13b91b0fb Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 22:48:43 -0700 Subject: [PATCH 372/423] realm [nfc]: Move proxy boilerplate out of central store.dart --- lib/model/realm.dart | 34 ++++++++++++++++++++++++++++++++++ lib/model/store.dart | 30 +++--------------------------- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 3f0bf05f55..b4c2497739 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; @@ -34,6 +36,38 @@ mixin RealmStore { EmailAddressVisibility? get emailAddressVisibility; } +mixin ProxyRealmStore on RealmStore { + @protected + RealmStore get realmStore; + + @override + int get serverPresencePingIntervalSeconds => realmStore.serverPresencePingIntervalSeconds; + @override + int get serverPresenceOfflineThresholdSeconds => realmStore.serverPresenceOfflineThresholdSeconds; + @override + RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; + @override + bool get realmMandatoryTopics => realmStore.realmMandatoryTopics; + @override + int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold; + @override + bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; + @override + int? get realmMessageContentEditLimitSeconds => realmStore.realmMessageContentEditLimitSeconds; + @override + bool get realmPresenceDisabled => realmStore.realmPresenceDisabled; + @override + int get maxFileUploadSizeMib => realmStore.maxFileUploadSizeMib; + @override + String get realmEmptyTopicDisplayName => realmStore.realmEmptyTopicDisplayName; + @override + Map get realmDefaultExternalAccounts => realmStore.realmDefaultExternalAccounts; + @override + List get customProfileFields => realmStore.customProfileFields; + @override + EmailAddressVisibility? get emailAddressVisibility => realmStore.emailAddressVisibility; +} + /// The implementation of [RealmStore] that does the work. class RealmStoreImpl extends PerAccountStoreBase with RealmStore { RealmStoreImpl({ diff --git a/lib/model/store.dart b/lib/model/store.dart index 508700713c..0a848c1e31 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -438,7 +438,7 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, UserGroupStore, ProxyUserGroupStore, - RealmStore, + RealmStore, ProxyRealmStore, EmojiStore, SavedSnippetStore, UserStore, @@ -579,33 +579,9 @@ class PerAccountStore extends PerAccountStoreBase with UserGroupStore get userGroupStore => _groups; final UserGroupStoreImpl _groups; + @protected @override - int get serverPresencePingIntervalSeconds => _realm.serverPresencePingIntervalSeconds; - @override - int get serverPresenceOfflineThresholdSeconds => _realm.serverPresenceOfflineThresholdSeconds; - @override - RealmWildcardMentionPolicy get realmWildcardMentionPolicy => _realm.realmWildcardMentionPolicy; - @override - bool get realmMandatoryTopics => _realm.realmMandatoryTopics; - @override - int get realmWaitingPeriodThreshold => _realm.realmWaitingPeriodThreshold; - @override - bool get realmAllowMessageEditing => _realm.realmAllowMessageEditing; - @override - int? get realmMessageContentEditLimitSeconds => _realm.realmMessageContentEditLimitSeconds; - @override - bool get realmPresenceDisabled => _realm.realmPresenceDisabled; - @override - int get maxFileUploadSizeMib => _realm.maxFileUploadSizeMib; - @override - String get realmEmptyTopicDisplayName => _realm.realmEmptyTopicDisplayName; - @override - Map get realmDefaultExternalAccounts => _realm.realmDefaultExternalAccounts; - @override - List get customProfileFields => _realm.customProfileFields; - @override - EmailAddressVisibility? get emailAddressVisibility => _realm.emailAddressVisibility; - + RealmStore get realmStore => _realm; final RealmStoreImpl _realm; //////////////////////////////// From a2fa946e2dd6c4d03139e70e066ef0a307839bde Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:27:03 -0700 Subject: [PATCH 373/423] store [nfc]: Expose PerAccountStoreBase.core to subclasses --- lib/model/store.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 0a848c1e31..f7ef990844 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -364,19 +364,19 @@ class CorePerAccountStore { /// A base class for [PerAccountStore] and its substores, /// with getters providing the items in [CorePerAccountStore]. abstract class PerAccountStoreBase { - PerAccountStoreBase({required CorePerAccountStore core}) - : _core = core; + PerAccountStoreBase({required this.core}); - final CorePerAccountStore _core; + @protected + final CorePerAccountStore core; //////////////////////////////// // Where data comes from in the first place. - GlobalStore get _globalStore => _core._globalStore; + GlobalStore get _globalStore => core._globalStore; - ApiConnection get connection => _core.connection; + ApiConnection get connection => core.connection; - String get queueId => _core.queueId; + String get queueId => core.queueId; //////////////////////////////// // Data attached to the realm or the server. @@ -398,7 +398,7 @@ abstract class PerAccountStoreBase { //////////////////////////////// // Data attached to the self-account on the realm. - int get accountId => _core.accountId; + int get accountId => core.accountId; /// The [Account] this store belongs to. /// @@ -413,7 +413,7 @@ abstract class PerAccountStoreBase { /// This always equals the [Account.userId] on [account]. /// /// For the corresponding [User] object, see [UserStore.selfUser]. - int get selfUserId => _core.selfUserId; + int get selfUserId => core.selfUserId; } const _tryResolveUrl = tryResolveUrl; From fea9e018cb6bc2d8dd6526458bdbd9c57560c26b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 22:54:32 -0700 Subject: [PATCH 374/423] realm [nfc]: Add HasRealmStore for other substores to use The other substores could always handle this themselves individually, like Unreads does today with ChannelStore. But this is one substore that lots of others are going to want references to, so we might as well make that a bit easier. --- lib/model/realm.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index b4c2497739..d707c2a142 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -10,7 +10,8 @@ import 'store.dart'; /// /// See also: /// * [RealmStoreImpl] for the implementation of this that does the work. -mixin RealmStore { +/// * [HasRealmStore] for an implementation useful for other substores. +mixin RealmStore on PerAccountStoreBase { int get serverPresencePingIntervalSeconds; int get serverPresenceOfflineThresholdSeconds; @@ -68,6 +69,17 @@ mixin ProxyRealmStore on RealmStore { EmailAddressVisibility? get emailAddressVisibility => realmStore.emailAddressVisibility; } +/// A base class for [PerAccountStore] substores that need access to [RealmStore] +/// as well as to [CorePerAccountStore]. +abstract class HasRealmStore extends PerAccountStoreBase with RealmStore, ProxyRealmStore { + HasRealmStore({required RealmStore realm}) + : realmStore = realm, super(core: realm.core); + + @protected + @override + final RealmStore realmStore; +} + /// The implementation of [RealmStore] that does the work. class RealmStoreImpl extends PerAccountStoreBase with RealmStore { RealmStoreImpl({ From 4b56bbac699f8ff32a087b180cbe2d5975216781 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 22 Jul 2025 18:47:00 -0700 Subject: [PATCH 375/423] narrow [nfc]: Inline away TopicNarrow.processTopicLikeServer This will help us move the underlying logic to live on a substore, which in turn will let us save these advance lookups and the annoying zulipFeatureLevel conditional. --- lib/model/narrow.dart | 18 ------------------ lib/widgets/message_list.dart | 14 ++++++++------ 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index e8c667ef31..ff4ccfbbc0 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -108,24 +108,6 @@ class TopicNarrow extends Narrow implements SendableNarrow { TopicNarrow sansWith() => TopicNarrow(streamId, topic); - /// Process [topic] to match how it would appear on a message object from - /// the server. - /// - /// Returns a new [TopicNarrow] with the [topic] processed. - /// - /// See [TopicName.processLikeServer]. - TopicNarrow processTopicLikeServer({ - required int zulipFeatureLevel, - required String? realmEmptyTopicDisplayName, - }) { - return TopicNarrow( - streamId, - topic.processLikeServer( - zulipFeatureLevel: zulipFeatureLevel, - realmEmptyTopicDisplayName: realmEmptyTopicDisplayName), - with_: with_); - } - @override bool containsMessage(MessageBase message) { final conversation = message.conversation; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 0371a29b75..a510589975 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -774,14 +774,16 @@ class _MessageListState extends State with PerAccountStoreAwareStat } void _initModel(PerAccountStore store, Anchor anchor) { - // Normalize topic name if this is a TopicNarrow. See #1717. var narrow = widget.narrow; if (narrow is TopicNarrow) { - narrow = narrow.processTopicLikeServer( - zulipFeatureLevel: store.zulipFeatureLevel, - realmEmptyTopicDisplayName: store.zulipFeatureLevel > 334 - ? store.realmEmptyTopicDisplayName - : null); + // Normalize topic name. See #1717. + narrow = TopicNarrow(narrow.streamId, + narrow.topic.processLikeServer( + zulipFeatureLevel: store.zulipFeatureLevel, + realmEmptyTopicDisplayName: store.zulipFeatureLevel > 334 + ? store.realmEmptyTopicDisplayName + : null), + with_: narrow.with_); if (narrow != widget.narrow) { SchedulerBinding.instance.scheduleFrameCallback((_) { widget.onNarrowChanged(narrow); From 45acceec9101f3dc4c0f60c3301c638885dc93e1 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 22:58:01 -0700 Subject: [PATCH 376/423] realm [nfc]: Move processTopicLikeServer to substore, from API code This way, the method's implementation can look up zulipFeatureLevel and realmEmptyTopicDisplayName for itself, rather than have them looked up in advance by the caller and passed in. Then as a further consequence, the method only looks up realmEmptyTopicDisplayName if and when it determines it actually needs that information. That makes the lookup compatible with the check we have in the main realmEmptyTopicDisplayName getter to assert that it's only consulted when it should in fact be needed, on old servers. A hack in the message store, and a similar hack on the message list, therefore become unnecessary. --- lib/api/model/model.dart | 47 -------------------------------- lib/model/message.dart | 50 ++++++++++++++-------------------- lib/model/realm.dart | 45 ++++++++++++++++++++++++++++++ lib/model/store.dart | 6 ++-- lib/widgets/message_list.dart | 6 +--- test/api/model/model_test.dart | 25 ----------------- test/model/realm_test.dart | 28 +++++++++++++++++++ 7 files changed, 97 insertions(+), 110 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 32d222d620..4302a82c11 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -837,53 +837,6 @@ 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/model/message.dart b/lib/model/message.dart index a7a6983c4d..5d4e367d62 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -12,6 +12,7 @@ import '../api/route/messages.dart'; import '../log.dart'; import 'binding.dart'; import 'message_list.dart'; +import 'realm.dart'; import 'store.dart'; const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 @@ -102,20 +103,12 @@ class _EditMessageRequestStatus { final String newContent; } -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 +class MessageStoreImpl extends HasRealmStore with MessageStore, _OutboxMessageStore { + MessageStoreImpl({required super.realm}) + : // There are no messages in InitialSnapshot, so we don't have // a use case for initializing MessageStore with nonempty [messages]. messages = {}; - // This copy of the realm setting is here to bypass the feature-level check - // on the usual getter for it. See discussion: - // https://github.com/zulip/zulip-flutter/pull/1472#discussion_r2099069276 - // TODO move [TopicName.processLikeServer] to a substore, eliminating this - @override - final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting - @override final Map messages; @@ -771,9 +764,7 @@ class DmOutboxMessage extends OutboxMessage { } /// Manages the outbox messages portion of [MessageStore]. -mixin _OutboxMessageStore on PerAccountStoreBase { - String? get _realmEmptyTopicDisplayName; - +mixin _OutboxMessageStore on HasRealmStore { late final UnmodifiableMapView outboxMessages = UnmodifiableMapView(_outboxMessages); final Map _outboxMessages = {}; @@ -918,22 +909,21 @@ mixin _OutboxMessageStore on PerAccountStoreBase { } TopicName _processTopicLikeServer(TopicName topic) { - 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); + // Processing this just once on creating the outbox message + // allows an uncommon bug, because either of the values + // [zulipFeatureLevel] or [realmEmptyTopicDisplayName] 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. + return processTopicLikeServer(topic); } void _handleOutboxDebounce(int localMessageId) { diff --git a/lib/model/realm.dart b/lib/model/realm.dart index d707c2a142..0abd432f56 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -35,6 +35,51 @@ mixin RealmStore on PerAccountStoreBase { List get customProfileFields; /// For docs, please see [InitialSnapshot.emailAddressVisibility]. EmailAddressVisibility? get emailAddressVisibility; + + /// Process the given 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 the given [TopicName] + /// in a [sendMessage] request. + /// + /// The [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 processTopicLikeServer(TopicName topic) { + final apiName = topic.apiName; + assert(apiName.trim() == apiName); + // 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(apiName.isNotEmpty); + return topic; + } + + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 370 && apiName == 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 (apiName == kNoTopicTopic || apiName == 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 topic; + } } mixin ProxyRealmStore on RealmStore { diff --git a/lib/model/store.dart b/lib/model/store.dart index f7ef990844..32ac34c34e 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -483,11 +483,12 @@ class PerAccountStore extends PerAccountStoreBase with selfUserId: account.userId, ); final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot); + final realm = RealmStoreImpl(core: core, initialSnapshot: initialSnapshot); return PerAccountStore._( core: core, groups: UserGroupStoreImpl(core: core, groups: initialSnapshot.realmUserGroups), - realm: RealmStoreImpl(core: core, initialSnapshot: initialSnapshot), + realm: realm, emoji: EmojiStoreImpl( core: core, allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, @@ -509,8 +510,7 @@ class PerAccountStore extends PerAccountStoreBase with realmPresenceDisabled: initialSnapshot.realmPresenceDisabled, initial: initialSnapshot.presences), channels: channels, - messages: MessageStoreImpl(core: core, - realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName), + messages: MessageStoreImpl(realm: realm), unreads: Unreads( initial: initialSnapshot.unreadMsgs, core: core, diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index a510589975..eccdf8c6a6 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -778,11 +778,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat if (narrow is TopicNarrow) { // Normalize topic name. See #1717. narrow = TopicNarrow(narrow.streamId, - narrow.topic.processLikeServer( - zulipFeatureLevel: store.zulipFeatureLevel, - realmEmptyTopicDisplayName: store.zulipFeatureLevel > 334 - ? store.realmEmptyTopicDisplayName - : null), + store.processTopicLikeServer(narrow.topic), with_: narrow.with_); if (narrow != widget.narrow) { SchedulerBinding.instance.scheduleFrameCallback((_) { diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index fa0445ca98..8717ebbb6e 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -227,31 +227,6 @@ 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', () { diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart index 7dfe240686..5aecfd289d 100644 --- a/test/model/realm_test.dart +++ b/test/model/realm_test.dart @@ -47,4 +47,32 @@ void main() { check(store.customProfileFields.map((f) => f.id)).deepEquals([2, 0, 1]); }); }); + + test('processTopicLikeServer', () { + final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName; + + TopicName process(TopicName topic, int zulipFeatureLevel) { + final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: emptyTopicDisplayName)); + return store.processTopicLikeServer(topic); + } + + void doCheck(TopicName topic, TopicName expected, int zulipFeatureLevel) { + check(process(topic, zulipFeatureLevel)).equals(expected); + } + + check(() => process(eg.t(''), 333)).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); + }); } From b30df8b1a63f9202a8c4212699508851f920b1f2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 23:40:53 -0700 Subject: [PATCH 377/423] realm [nfc]: Add Duration getters for durations --- lib/model/realm.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 0abd432f56..c007f9d83d 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -12,7 +12,9 @@ import 'store.dart'; /// * [RealmStoreImpl] for the implementation of this that does the work. /// * [HasRealmStore] for an implementation useful for other substores. mixin RealmStore on PerAccountStoreBase { + Duration get serverPresencePingInterval => Duration(seconds: serverPresencePingIntervalSeconds); int get serverPresencePingIntervalSeconds; + Duration get serverPresenceOfflineThreshold => Duration(seconds: serverPresenceOfflineThresholdSeconds); int get serverPresenceOfflineThresholdSeconds; RealmWildcardMentionPolicy get realmWildcardMentionPolicy; @@ -20,6 +22,9 @@ mixin RealmStore on PerAccountStoreBase { /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. int get realmWaitingPeriodThreshold; bool get realmAllowMessageEditing; + Duration? get realmMessageContentEditLimit => + realmMessageContentEditLimitSeconds == null ? null + : Duration(seconds: realmMessageContentEditLimitSeconds!); int? get realmMessageContentEditLimitSeconds; bool get realmPresenceDisabled; int get maxFileUploadSizeMib; From 290067af204aeb13f9eb5cd9228902975fcf1f05 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 22:54:32 -0700 Subject: [PATCH 378/423] presence [nfc]: Use RealmStore for server/realm settings --- lib/model/presence.dart | 15 +++------------ lib/model/store.dart | 5 +---- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/lib/model/presence.dart b/lib/model/presence.dart index d21ece421a..590c2d3ceb 100644 --- a/lib/model/presence.dart +++ b/lib/model/presence.dart @@ -6,7 +6,7 @@ import 'package:flutter/widgets.dart'; import '../api/model/events.dart'; import '../api/model/model.dart'; import '../api/route/users.dart'; -import 'store.dart'; +import 'realm.dart'; /// The model for tracking which users are online, idle, and offline. /// @@ -16,21 +16,12 @@ import 'store.dart'; /// 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 { +class Presence extends HasRealmStore with ChangeNotifier { Presence({ - required super.core, - required this.serverPresencePingInterval, - required this.serverPresenceOfflineThresholdSeconds, - required this.realmPresenceDisabled, + required super.realm, 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; diff --git a/lib/model/store.dart b/lib/model/store.dart index 32ac34c34e..c9c2caa4d2 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -504,10 +504,7 @@ class PerAccountStore extends PerAccountStoreBase with users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot), typingStatus: TypingStatus(core: core, typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds)), - presence: Presence(core: core, - serverPresencePingInterval: Duration(seconds: initialSnapshot.serverPresencePingIntervalSeconds), - serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds, - realmPresenceDisabled: initialSnapshot.realmPresenceDisabled, + presence: Presence(realm: realm, initial: initialSnapshot.presences), channels: channels, messages: MessageStoreImpl(realm: realm), From 49539ff2b163f366d00fa63cdf2dfc48a667d48b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 23:43:22 -0700 Subject: [PATCH 379/423] realm [nfc]: Add server settings for typing status --- lib/model/realm.dart | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index c007f9d83d..e3c536589b 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -17,6 +17,13 @@ mixin RealmStore on PerAccountStoreBase { Duration get serverPresenceOfflineThreshold => Duration(seconds: serverPresenceOfflineThresholdSeconds); int get serverPresenceOfflineThresholdSeconds; + Duration get serverTypingStartedExpiryPeriod => Duration(milliseconds: serverTypingStartedExpiryPeriodMilliseconds); + int get serverTypingStartedExpiryPeriodMilliseconds; + Duration get serverTypingStoppedWaitPeriod => Duration(milliseconds: serverTypingStoppedWaitPeriodMilliseconds); + int get serverTypingStoppedWaitPeriodMilliseconds; + Duration get serverTypingStartedWaitPeriod => Duration(milliseconds: serverTypingStartedWaitPeriodMilliseconds); + int get serverTypingStartedWaitPeriodMilliseconds; + RealmWildcardMentionPolicy get realmWildcardMentionPolicy; bool get realmMandatoryTopics; /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. @@ -96,6 +103,12 @@ mixin ProxyRealmStore on RealmStore { @override int get serverPresenceOfflineThresholdSeconds => realmStore.serverPresenceOfflineThresholdSeconds; @override + int get serverTypingStartedExpiryPeriodMilliseconds => realmStore.serverTypingStartedExpiryPeriodMilliseconds; + @override + int get serverTypingStoppedWaitPeriodMilliseconds => realmStore.serverTypingStoppedWaitPeriodMilliseconds; + @override + int get serverTypingStartedWaitPeriodMilliseconds => realmStore.serverTypingStartedWaitPeriodMilliseconds; + @override RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; @override bool get realmMandatoryTopics => realmStore.realmMandatoryTopics; @@ -138,6 +151,9 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { }) : serverPresencePingIntervalSeconds = initialSnapshot.serverPresencePingIntervalSeconds, serverPresenceOfflineThresholdSeconds = initialSnapshot.serverPresenceOfflineThresholdSeconds, + serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds, + serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds, + serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds, realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy, realmMandatoryTopics = initialSnapshot.realmMandatoryTopics, realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold, @@ -155,6 +171,13 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { @override final int serverPresenceOfflineThresholdSeconds; + @override + final int serverTypingStartedExpiryPeriodMilliseconds; + @override + final int serverTypingStoppedWaitPeriodMilliseconds; + @override + final int serverTypingStartedWaitPeriodMilliseconds; + @override final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting @override From 2321e89ca25c1e79ebbecae8355b4f9e875b9387 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Jul 2025 23:46:48 -0700 Subject: [PATCH 380/423] typing_status [nfc]: Use RealmStore for server settings --- lib/model/store.dart | 11 ++--------- lib/model/typing_status.dart | 28 ++++++++-------------------- test/model/typing_status_test.dart | 24 ++++++++++++------------ test/widgets/compose_box_test.dart | 10 +++++----- 4 files changed, 27 insertions(+), 46 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index c9c2caa4d2..c0aa965ae5 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -494,16 +494,9 @@ class PerAccountStore extends PerAccountStoreBase with userSettings: initialSnapshot.userSettings, savedSnippets: SavedSnippetStoreImpl( core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), - typingNotifier: TypingNotifier( - core: core, - typingStoppedWaitPeriod: Duration( - milliseconds: initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds), - typingStartedWaitPeriod: Duration( - milliseconds: initialSnapshot.serverTypingStartedWaitPeriodMilliseconds), - ), + typingNotifier: TypingNotifier(realm: realm), users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot), - typingStatus: TypingStatus(core: core, - typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds)), + typingStatus: TypingStatus(realm: realm), presence: Presence(realm: realm, initial: initialSnapshot.presences), channels: channels, diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index cce9b2568f..fdb63339bc 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -6,18 +6,13 @@ import '../api/model/events.dart'; import '../api/route/typing.dart'; import 'binding.dart'; import 'narrow.dart'; -import 'store.dart'; +import 'realm.dart'; /// The model for tracking the typing status organized by narrows. /// /// Listeners are notified when a typist is added or removed from any narrow. -class TypingStatus extends PerAccountStoreBase with ChangeNotifier { - TypingStatus({ - required super.core, - required this.typingStartedExpiryPeriod, - }); - - final Duration typingStartedExpiryPeriod; +class TypingStatus extends HasRealmStore with ChangeNotifier { + TypingStatus({required super.realm}); Iterable get debugActiveNarrows => _timerMapsByNarrow.keys; @@ -47,7 +42,7 @@ class TypingStatus extends PerAccountStoreBase with ChangeNotifier { final typistTimer = narrowTimerMap[typistUserId]; final isNewTypist = typistTimer == null; typistTimer?.cancel(); - narrowTimerMap[typistUserId] = Timer(typingStartedExpiryPeriod, () { + narrowTimerMap[typistUserId] = Timer(serverTypingStartedExpiryPeriod, () { if (_removeTypist(narrow, typistUserId)) { notifyListeners(); } @@ -92,15 +87,8 @@ class TypingStatus extends PerAccountStoreBase with ChangeNotifier { /// See also: /// * https://github.com/zulip/zulip/blob/52a9846cdf4abfbe937a94559690d508e95f4065/web/shared/src/typing_status.ts /// * https://zulip.readthedocs.io/en/latest/subsystems/typing-indicators.html -class TypingNotifier extends PerAccountStoreBase { - TypingNotifier({ - required super.core, - required this.typingStoppedWaitPeriod, - required this.typingStartedWaitPeriod, - }); - - final Duration typingStoppedWaitPeriod; - final Duration typingStartedWaitPeriod; +class TypingNotifier extends HasRealmStore { + TypingNotifier({required super.realm}); SendableNarrow? _currentDestination; @@ -137,7 +125,7 @@ class TypingNotifier extends PerAccountStoreBase { if (destination == _currentDestination) { // Nothing has really changed, except we may need // to send a ping to the server and extend out our idle time. - if (_sinceLastPing!.elapsed > typingStartedWaitPeriod) { + if (_sinceLastPing!.elapsed > serverTypingStartedWaitPeriod) { _actuallyPingServer(); } _startOrExtendIdleTimer(); @@ -179,7 +167,7 @@ class TypingNotifier extends PerAccountStoreBase { void _startOrExtendIdleTimer() { _idleTimer?.cancel(); - _idleTimer = Timer(typingStoppedWaitPeriod, _stopLastNotification); + _idleTimer = Timer(serverTypingStoppedWaitPeriod, _stopLastNotification); } void _actuallyPingServer() { diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart index 01d817680a..8d293940ad 100644 --- a/test/model/typing_status_test.dart +++ b/test/model/typing_status_test.dart @@ -297,7 +297,7 @@ void main() { const waitTime = Duration(milliseconds: 100); // [waitTime] should not be long enough // to trigger a "typing stopped" notice. - assert(waitTime < model.typingStoppedWaitPeriod); + assert(waitTime < store.serverTypingStoppedWaitPeriod); async.elapse(waitTime); // t = 100ms: The idle timer is reset to typingStoppedWaitPeriod. @@ -306,7 +306,7 @@ void main() { check(connection.lastRequest).isNull(); check(async.pendingTimers).single; - async.elapse(model.typingStoppedWaitPeriod - const Duration(milliseconds: 1)); + async.elapse(store.serverTypingStoppedWaitPeriod - const Duration(milliseconds: 1)); // t = typingStoppedWaitPeriod + 99ms: // Since the timer was reset at t = 100ms, the "typing stopped" notice has // not been sent yet. @@ -326,12 +326,12 @@ void main() { const waitInterval = Duration(milliseconds: 2000); // [waitInterval] should not be long enough // to trigger a "typing stopped" notice. - assert(waitInterval < model.typingStoppedWaitPeriod); + assert(waitInterval < store.serverTypingStoppedWaitPeriod); // [waitInterval] should be short enough // that the loop below runs more than once. - assert(waitInterval < model.typingStartedWaitPeriod); + assert(waitInterval < store.serverTypingStartedWaitPeriod); - while (async.elapsed <= model.typingStartedWaitPeriod) { + while (async.elapsed <= store.serverTypingStartedWaitPeriod) { // t <= typingStartedWaitPeriod: "Typing started" notices are throttled. model.keystroke(narrow); check(connection.lastRequest).isNull(); @@ -354,7 +354,7 @@ void main() { await prepareStartTyping(async); connection.prepare(json: {}); - async.elapse(model.typingStoppedWaitPeriod); + async.elapse(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); check(async.pendingTimers).isEmpty(); })); @@ -406,7 +406,7 @@ void main() { const waitTime = Duration(milliseconds: 100); // [waitTime] should not be long enough // to trigger a "typing stopped" notice. - assert(waitTime < model.typingStoppedWaitPeriod); + assert(waitTime < store.serverTypingStoppedWaitPeriod); // t = 0ms: Start typing. The idle timer is set to typingStoppedWaitPeriod. connection.prepare(json: {}); @@ -429,7 +429,7 @@ void main() { async.elapse(Duration.zero); check(async.pendingTimers).single; - async.elapse(model.typingStoppedWaitPeriod - waitTime); + async.elapse(store.serverTypingStoppedWaitPeriod - waitTime); // t = typingStoppedPeriod: // Because the old timer has been canceled at t = 100ms, // no "typing stopped" notice has been sent yet. @@ -452,7 +452,7 @@ void main() { const waitInterval = Duration(milliseconds: 2000); // [waitInterval] should not be long enough // to trigger a "typing stopped" notice. - assert(waitInterval < model.typingStoppedWaitPeriod); + assert(waitInterval < store.serverTypingStoppedWaitPeriod); // t = 0ms: Start typing. The typing started time is set to 0ms. connection.prepare(json: {}); @@ -471,7 +471,7 @@ void main() { checkSetTypingStatusRequests(connection.takeRequests(), [(TypingOp.stop, topicNarrow), (TypingOp.start, dmNarrow)]); - while (async.elapsed <= model.typingStartedWaitPeriod) { + while (async.elapsed <= store.serverTypingStartedWaitPeriod) { // t <= typingStartedWaitPeriod: "still typing" requests are throttled. model.keystroke(dmNarrow); check(connection.lastRequest).isNull(); @@ -479,8 +479,8 @@ void main() { async.elapse(waitInterval); } - assert(async.elapsed > model.typingStartedWaitPeriod); - assert(async.elapsed <= model.typingStartedWaitPeriod + waitInterval); + assert(async.elapsed > store.serverTypingStartedWaitPeriod); + assert(async.elapsed <= store.serverTypingStartedWaitPeriod + waitInterval); // typingStartedWaitPeriod < t <= typingStartedWaitPeriod + waitInterval * 1: // The "still typing" requests are still throttled, because it hasn't // been a full typingStartedWaitPeriod since the last time we sent diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index dd7a71d297..f39e74c138 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -761,7 +761,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); @@ -773,7 +773,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); @@ -786,7 +786,7 @@ void main() { await checkStartTyping(tester, destinationNarrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, destinationNarrow); }); @@ -866,7 +866,7 @@ void main() { await checkStartTyping(tester, narrow); connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); connection.prepare(json: {}); @@ -876,7 +876,7 @@ void main() { // Ensures that a "typing stopped" notice is sent when the test ends. connection.prepare(json: {}); - await tester.pump(store.typingNotifier.typingStoppedWaitPeriod); + await tester.pump(store.serverTypingStoppedWaitPeriod); checkTypingRequest(TypingOp.stop, narrow); }); From 86ce0252aacce0e2fa5c1983ff564c09f3984f52 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 16:12:16 -0700 Subject: [PATCH 381/423] dartdoc: Stop section-divider comments from getting absorbed into docs Before this change, if you hover in your IDE over an identifier whose declaration happens to be the first one after one of these section dividers, the doc shown would begin with a long string of slashes. After all, the divider line did start with "///". The choice of "|" as the substitute was fairly arbitrary. I tried a space first, and felt it interrupted the divider too much and made it look like just another line of comments. Done with a bit of Perl and Git: $ perl -i -0pe 's,^\s*//\K(?=//),|,gm' $(git grep -l //// '*.dart') --- lib/example/sticky_header.dart | 4 ++-- lib/model/content.dart | 2 +- lib/model/store.dart | 26 +++++++++++++------------- test/example_data.dart | 20 ++++++++++---------- test/flutter_checks.dart | 16 ++++++++-------- test/model/binding.dart | 6 +++--- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart index fc6f42cd64..5c96cb6a00 100644 --- a/lib/example/sticky_header.dart +++ b/lib/example/sticky_header.dart @@ -198,14 +198,14 @@ class ExampleVerticalDouble extends StatelessWidget { } } -//////////////////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////////////////// // // That's it! // // The rest of this file is boring infrastructure for navigating to the // different examples, and for having some content to put inside them. // -//////////////////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////////////////// class WideHeader extends StatelessWidget { const WideHeader({super.key, required this.i}); diff --git a/lib/model/content.dart b/lib/model/content.dart index 78d7af24bc..4413857173 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -1016,7 +1016,7 @@ class GlobalTimeNode extends InlineContentNode { } } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// /// Parser for the inline-content subtrees within Zulip content HTML. /// diff --git a/lib/model/store.dart b/lib/model/store.dart index c0aa965ae5..a8b308b450 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -369,7 +369,7 @@ abstract class PerAccountStoreBase { @protected final CorePerAccountStore core; - //////////////////////////////// + //|////////////////////////////// // Where data comes from in the first place. GlobalStore get _globalStore => core._globalStore; @@ -378,7 +378,7 @@ abstract class PerAccountStoreBase { String get queueId => core.queueId; - //////////////////////////////// + //|////////////////////////////// // Data attached to the realm or the server. /// Always equal to `account.realmUrl` and `connection.realmUrl`. @@ -395,7 +395,7 @@ abstract class PerAccountStoreBase { String get zulipVersion => account.zulipVersion; - //////////////////////////////// + //|////////////////////////////// // Data attached to the self-account on the realm. int get accountId => core.accountId; @@ -536,10 +536,10 @@ class PerAccountStore extends PerAccountStoreBase with _channels = channels, _messages = messages; - //////////////////////////////////////////////////////////////// + //|////////////////////////////////////////////////////////////// // Data. - //////////////////////////////// + //|////////////////////////////// // Where data comes from in the first place. UpdateMachine? get updateMachine => _updateMachine; @@ -559,7 +559,7 @@ class PerAccountStore extends PerAccountStoreBase with notifyListeners(); } - //////////////////////////////// + //|////////////////////////////// // Data attached to the realm or the server. // (User groups come before even realm settings, @@ -574,7 +574,7 @@ class PerAccountStore extends PerAccountStoreBase with RealmStore get realmStore => _realm; final RealmStoreImpl _realm; - //////////////////////////////// + //|////////////////////////////// // The realm's repertoire of available emoji. @override @@ -604,7 +604,7 @@ class PerAccountStore extends PerAccountStoreBase with EmojiStoreImpl _emoji; - //////////////////////////////// + //|////////////////////////////// // Data attached to the self-account on the realm. final UserSettings userSettings; @@ -615,7 +615,7 @@ class PerAccountStore extends PerAccountStoreBase with final TypingNotifier typingNotifier; - //////////////////////////////// + //|////////////////////////////// // Users and data about them. @override @@ -693,7 +693,7 @@ class PerAccountStore extends PerAccountStoreBase with } } - //////////////////////////////// + //|////////////////////////////// // Streams, topics, and stuff about them. @override @@ -735,7 +735,7 @@ class PerAccountStore extends PerAccountStoreBase with } } - //////////////////////////////// + //|////////////////////////////// // Messages, and summaries of messages. @override @@ -797,13 +797,13 @@ class PerAccountStore extends PerAccountStoreBase with final RecentSenders recentSenders; - //////////////////////////////// + //|////////////////////////////// // Other digests of data. final AutocompleteViewManager autocompleteViewManager = AutocompleteViewManager(); // End of data. - //////////////////////////////////////////////////////////////// + //|////////////////////////////////////////////////////////////// /// Called when the app is reassembled during debugging, e.g. for hot reload. /// diff --git a/test/example_data.dart b/test/example_data.dart index 5f0a79c4fb..c33c5fdfdc 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -25,7 +25,7 @@ void _checkPositive(int? value, String description) { assert(value == null || value > 0, '$description should be positive'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Error objects. // @@ -71,7 +71,7 @@ ZulipApiException apiExceptionUnauthorized({String routeName = 'someRoute'}) { data: {}, message: 'Invalid API key'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Time values. // @@ -85,7 +85,7 @@ int utcTimestamp([DateTime? dateTime]) { return dateTime.toUtc().millisecondsSinceEpoch ~/ 1000; } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Realm-wide (or server-wide) metadata. // @@ -231,7 +231,7 @@ RealmEmojiItem realmEmojiItem({ ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Users and accounts. // @@ -386,7 +386,7 @@ final Account otherAccount = account( apiKey: '6dxT4b73BYpCTU+i4BB9LAKC5h/CufqY', // A Zulip API key is 32 digits of base64. ); -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Data attached to the self-account on the realm // @@ -408,7 +408,7 @@ SavedSnippet savedSnippet({ ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Streams and subscriptions. // @@ -523,7 +523,7 @@ UserTopicItem userTopicItem( ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Messages, and pieces of messages. // @@ -867,7 +867,7 @@ Submessage submessage({ ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Aggregate data structures. // @@ -902,7 +902,7 @@ UnreadMessagesSnapshot unreadMsgs({ } const _unreadMsgs = unreadMsgs; -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // Events. // @@ -1175,7 +1175,7 @@ ChannelUpdateEvent channelUpdateEvent( ); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // The entire per-account or global state. // diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index c6e31bb1fa..562e34db2f 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From the Flutter engine, i.e. from dart:ui. // @@ -40,7 +40,7 @@ extension FontVariationChecks on Subject { Subject get value => has((x) => x.value, 'value'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/foundation.dart'. // @@ -48,7 +48,7 @@ extension ValueListenableChecks on Subject> { Subject get value => has((c) => c.value, 'value'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/services.dart'. // @@ -62,7 +62,7 @@ extension TextEditingValueChecks on Subject { Subject get composing => has((x) => x.composing, 'composing'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/animation.dart'. // @@ -71,7 +71,7 @@ extension AnimationChecks on Subject> { Subject get value => has((d) => d.value, 'value'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/painting.dart'. // @@ -97,7 +97,7 @@ extension InlineSpanChecks on Subject { Subject get style => has((x) => x.style, 'style'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/rendering.dart'. // @@ -110,7 +110,7 @@ extension RenderParagraphChecks on Subject { Subject get didExceedMaxLines => has((x) => x.didExceedMaxLines, 'didExceedMaxLines'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/widgets.dart'. // @@ -192,7 +192,7 @@ extension PageRouteChecks on Subject> { Subject get fullscreenDialog => has((x) => x.fullscreenDialog, 'fullscreenDialog'); } -//////////////////////////////////////////////////////////////// +//|////////////////////////////////////////////////////////////// // From 'package:flutter/material.dart'. // diff --git a/test/model/binding.dart b/test/model/binding.dart index 839242c1ce..ca9f43603f 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -422,7 +422,7 @@ class TestZulipBinding extends ZulipBinding { } class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { - //////////////////////////////// + //|////////////////////////////// // Permissions. NotificationSettings requestPermissionResult = const NotificationSettings( @@ -471,7 +471,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { return requestPermissionResult; } - //////////////////////////////// + //|////////////////////////////// // Tokens. String? _initialToken; @@ -527,7 +527,7 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { } } - //////////////////////////////// + //|////////////////////////////// // Messages. StreamController onMessage = StreamController.broadcast(); From 2b045d259a14aa625a6bb881e9ffd3fff94f85e2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:11:47 -0700 Subject: [PATCH 382/423] realm [nfc]: Organize realm settings, from current API doc To identify which fields are realm settings in the API, so that #668 applies to them, I looked at the current API docs: https://zulip.com/api/get-events#realm-update_dict It turns out a couple of items that had been realm settings are now something else; one that was a server setting (and even lacked "realm" in the name: maxFileUploadSizeMib) is now a realm setting updated by events. So the comments on the implementation fields need updating. I'll do that in a separate commit, though, so that this one is more purely moving code around. --- lib/model/realm.dart | 85 +++++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index e3c536589b..11762b8f8a 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -12,6 +12,9 @@ import 'store.dart'; /// * [RealmStoreImpl] for the implementation of this that does the work. /// * [HasRealmStore] for an implementation useful for other substores. mixin RealmStore on PerAccountStoreBase { + //|////////////////////////////////////////////////////////////// + // Server settings, explicitly so named. + Duration get serverPresencePingInterval => Duration(seconds: serverPresencePingIntervalSeconds); int get serverPresencePingIntervalSeconds; Duration get serverPresenceOfflineThreshold => Duration(seconds: serverPresenceOfflineThresholdSeconds); @@ -24,17 +27,35 @@ mixin RealmStore on PerAccountStoreBase { Duration get serverTypingStartedWaitPeriod => Duration(milliseconds: serverTypingStartedWaitPeriodMilliseconds); int get serverTypingStartedWaitPeriodMilliseconds; - RealmWildcardMentionPolicy get realmWildcardMentionPolicy; - bool get realmMandatoryTopics; - /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. - int get realmWaitingPeriodThreshold; + //|////////////////////////////////////////////////////////////// + // Realm settings. + + //|////////////////////////////// + // Realm settings found in realm/update_dict events: + // https://zulip.com/api/get-events#realm-update_dict + // TODO(#668): update all these realm settings on events. + bool get realmAllowMessageEditing; + bool get realmMandatoryTopics; + int get maxFileUploadSizeMib; Duration? get realmMessageContentEditLimit => realmMessageContentEditLimitSeconds == null ? null : Duration(seconds: realmMessageContentEditLimitSeconds!); int? get realmMessageContentEditLimitSeconds; bool get realmPresenceDisabled; - int get maxFileUploadSizeMib; + int get realmWaitingPeriodThreshold; + + //|////////////////////////////// + // Realm settings previously found in realm/update_dict events, + // but now deprecated. + + RealmWildcardMentionPolicy get realmWildcardMentionPolicy; // TODO(#662): replaced by can_mention_many_users_group + + EmailAddressVisibility? get emailAddressVisibility; // TODO: replaced at FL-163 by a user setting + + //|////////////////////////////// + // Realm settings that lack events. + // (Each of these is probably secretly a server setting.) /// The display name to use for empty topics. /// @@ -44,9 +65,14 @@ mixin RealmStore on PerAccountStoreBase { String get realmEmptyTopicDisplayName; Map get realmDefaultExternalAccounts; + + //|////////////////////////////// + // Realm settings with their own events. + List get customProfileFields; - /// For docs, please see [InitialSnapshot.emailAddressVisibility]. - EmailAddressVisibility? get emailAddressVisibility; + + //|////////////////////////////////////////////////////////////// + // Methods that examine the settings. /// Process the given topic to match how it would appear /// on a message object from the server. @@ -109,27 +135,27 @@ mixin ProxyRealmStore on RealmStore { @override int get serverTypingStartedWaitPeriodMilliseconds => realmStore.serverTypingStartedWaitPeriodMilliseconds; @override - RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; + bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; @override bool get realmMandatoryTopics => realmStore.realmMandatoryTopics; @override - int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold; - @override - bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; + int get maxFileUploadSizeMib => realmStore.maxFileUploadSizeMib; @override int? get realmMessageContentEditLimitSeconds => realmStore.realmMessageContentEditLimitSeconds; @override bool get realmPresenceDisabled => realmStore.realmPresenceDisabled; @override - int get maxFileUploadSizeMib => realmStore.maxFileUploadSizeMib; + int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold; + @override + RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; + @override + EmailAddressVisibility? get emailAddressVisibility => realmStore.emailAddressVisibility; @override String get realmEmptyTopicDisplayName => realmStore.realmEmptyTopicDisplayName; @override Map get realmDefaultExternalAccounts => realmStore.realmDefaultExternalAccounts; @override List get customProfileFields => realmStore.customProfileFields; - @override - EmailAddressVisibility? get emailAddressVisibility => realmStore.emailAddressVisibility; } /// A base class for [PerAccountStore] substores that need access to [RealmStore] @@ -154,17 +180,17 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds, serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds, serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds, - realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy, + realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, realmMandatoryTopics = initialSnapshot.realmMandatoryTopics, - realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold, - realmPresenceDisabled = initialSnapshot.realmPresenceDisabled, maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib, - _realmEmptyTopicDisplayName = initialSnapshot.realmEmptyTopicDisplayName, - realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, realmMessageContentEditLimitSeconds = initialSnapshot.realmMessageContentEditLimitSeconds, + realmPresenceDisabled = initialSnapshot.realmPresenceDisabled, + realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold, + realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy, + emailAddressVisibility = initialSnapshot.emailAddressVisibility, + _realmEmptyTopicDisplayName = initialSnapshot.realmEmptyTopicDisplayName, realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, - customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields), - emailAddressVisibility = initialSnapshot.emailAddressVisibility; + customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields); @override final int serverPresencePingIntervalSeconds; @@ -179,19 +205,23 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { final int serverTypingStartedWaitPeriodMilliseconds; @override - final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting + final bool realmAllowMessageEditing; // TODO(#668): update this realm setting @override final bool realmMandatoryTopics; // TODO(#668): update this realm setting @override - final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting - @override - final bool realmAllowMessageEditing; // TODO(#668): update this realm setting + final int maxFileUploadSizeMib; // No event for this. @override final int? realmMessageContentEditLimitSeconds; // TODO(#668): update this realm setting @override final bool realmPresenceDisabled; // TODO(#668): update this realm setting @override - final int maxFileUploadSizeMib; // No event for this. + final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting + + @override + final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting + + @override + final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting @override String get realmEmptyTopicDisplayName { @@ -222,9 +252,6 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { return displayFields.followedBy(nonDisplayFields).toList(); } - @override - final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting - void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) { customProfileFields = _sortCustomProfileFields(event.fields); } From 1e1132737e1b29588f6b59b2300b484f8be23b4a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:12:59 -0700 Subject: [PATCH 383/423] realm [nfc]: Cut duplicate, some out of date todo-comments on updating The accurate version of these is now covered by a todo-comment up on RealmStore. --- lib/model/realm.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 11762b8f8a..41e599580b 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -205,23 +205,23 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { final int serverTypingStartedWaitPeriodMilliseconds; @override - final bool realmAllowMessageEditing; // TODO(#668): update this realm setting + final bool realmAllowMessageEditing; @override - final bool realmMandatoryTopics; // TODO(#668): update this realm setting + final bool realmMandatoryTopics; @override - final int maxFileUploadSizeMib; // No event for this. + final int maxFileUploadSizeMib; @override - final int? realmMessageContentEditLimitSeconds; // TODO(#668): update this realm setting + final int? realmMessageContentEditLimitSeconds; @override - final bool realmPresenceDisabled; // TODO(#668): update this realm setting + final bool realmPresenceDisabled; @override - final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting + final int realmWaitingPeriodThreshold; @override - final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting + final RealmWildcardMentionPolicy realmWildcardMentionPolicy; @override - final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting + final EmailAddressVisibility? emailAddressVisibility; @override String get realmEmptyTopicDisplayName { @@ -229,7 +229,7 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { assert(_realmEmptyTopicDisplayName != null); // TODO(log) return _realmEmptyTopicDisplayName ?? 'general chat'; } - final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting + final String? _realmEmptyTopicDisplayName; @override final Map realmDefaultExternalAccounts; From 5d0188f5c1e2a562b6030c3a9c20613b223d98d9 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:37:03 -0700 Subject: [PATCH 384/423] store [nfc]: Organize substore parameters uniformly --- lib/model/store.dart | 15 ++++++--------- lib/model/unreads.dart | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index a8b308b450..73f4a6c264 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -489,11 +489,11 @@ class PerAccountStore extends PerAccountStoreBase with groups: UserGroupStoreImpl(core: core, groups: initialSnapshot.realmUserGroups), realm: realm, - emoji: EmojiStoreImpl( - core: core, allRealmEmoji: initialSnapshot.realmEmoji), + emoji: EmojiStoreImpl(core: core, + allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, - savedSnippets: SavedSnippetStoreImpl( - core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), + savedSnippets: SavedSnippetStoreImpl(core: core, + savedSnippets: initialSnapshot.savedSnippets ?? []), typingNotifier: TypingNotifier(realm: realm), users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot), typingStatus: TypingStatus(realm: realm), @@ -501,11 +501,8 @@ class PerAccountStore extends PerAccountStoreBase with initial: initialSnapshot.presences), channels: channels, messages: MessageStoreImpl(realm: realm), - unreads: Unreads( - initial: initialSnapshot.unreadMsgs, - core: core, - channelStore: channels, - ), + unreads: Unreads(core: core, channelStore: channels, + initial: initialSnapshot.unreadMsgs), recentDmConversationsView: RecentDmConversationsView(core: core, initial: initialSnapshot.recentPrivateConversations), recentSenders: RecentSenders(), diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index e65197a980..cda2fa5753 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -37,9 +37,9 @@ import 'store.dart'; // messages and refresh [mentions] (see [mentions] dartdoc). class Unreads extends PerAccountStoreBase with ChangeNotifier { factory Unreads({ - required UnreadMessagesSnapshot initial, required CorePerAccountStore core, required ChannelStore channelStore, + required UnreadMessagesSnapshot initial, }) { final streams = >>{}; final dms = >{}; From db8809bbfb6dfad58a9d7170706d98dba1c67a36 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 15:02:38 -0700 Subject: [PATCH 385/423] emoji [nfc]: Drop setServerEmojiData from main interface Instead, leave it as a method on EmojiStoreImpl and on the overall PerAccountStore. Each of these FooStore types, such as EmojiStore, is implemented by both a FooStoreImpl class and the overall PerAccountStore. Nearly all of these types' methods behave exactly the same on both those types; the one implementation just forwards to the other. But this method behaves slightly differently between them: the PerAccountStore implementation will call notifyListeners, while the EmojiStoreImpl implementation doesn't (because it can't, not being the ChangeNotifier itself). That mismatch, when nearly all the other similar methods have an exact match, risks confusion. Mitigate that by removing the method from the EmojiStore interface, so that the two implementations become unrelated methods. (The one other such mismatch is `reconcileMessages`. We'll deal with that one too, in a later commit.) --- lib/model/emoji.dart | 3 --- lib/model/store.dart | 11 +++++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 0923fdab79..7f4fac78a5 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -122,8 +122,6 @@ mixin EmojiStore { // TODO cut debugServerEmojiData once we can query for lists of emoji; // have tests make those queries end-to-end Map>? get debugServerEmojiData; - - void setServerEmojiData(ServerEmojiData data); } /// The implementation of [EmojiStore] that does the work. @@ -374,7 +372,6 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { return _allEmojiCandidates ??= _generateAllCandidates(); } - @override void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; _popularCandidates = null; diff --git a/lib/model/store.dart b/lib/model/store.dart index 73f4a6c264..eab9ebd619 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -587,18 +587,17 @@ class PerAccountStore extends PerAccountStoreBase with @override Map>? get debugServerEmojiData => _emoji.debugServerEmojiData; - @override - void setServerEmojiData(ServerEmojiData data) { - _emoji.setServerEmojiData(data); - notifyListeners(); - } - @override Iterable popularEmojiCandidates() => _emoji.popularEmojiCandidates(); @override Iterable allEmojiCandidates() => _emoji.allEmojiCandidates(); + void setServerEmojiData(ServerEmojiData data) { + _emoji.setServerEmojiData(data); + notifyListeners(); + } + EmojiStoreImpl _emoji; //|////////////////////////////// From 1d621469d886613566bcdc985ca374b6b0464e31 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 21:07:46 -0700 Subject: [PATCH 386/423] emoji [nfc]: Move proxy boilerplate out to substore file --- lib/model/emoji.dart | 24 ++++++++++++++++++++++++ lib/model/store.dart | 29 +++++------------------------ 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 7f4fac78a5..0b8ae60333 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -124,6 +124,30 @@ mixin EmojiStore { Map>? get debugServerEmojiData; } +mixin ProxyEmojiStore on EmojiStore { + @protected + EmojiStore get emojiStore; + + @override + EmojiDisplay emojiDisplayFor({ + required ReactionType emojiType, + required String emojiCode, + required String emojiName + }) { + return emojiStore.emojiDisplayFor( + emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName); + } + + @override + Iterable popularEmojiCandidates() => emojiStore.popularEmojiCandidates(); + + @override + Iterable allEmojiCandidates() => emojiStore.allEmojiCandidates(); + + @override + Map>? get debugServerEmojiData => emojiStore.debugServerEmojiData; +} + /// The implementation of [EmojiStore] that does the work. /// /// Generally the only code that should need this class is [PerAccountStore] diff --git a/lib/model/store.dart b/lib/model/store.dart index eab9ebd619..b3515f8137 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -439,7 +439,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, UserGroupStore, ProxyUserGroupStore, RealmStore, ProxyRealmStore, - EmojiStore, + EmojiStore, ProxyEmojiStore, SavedSnippetStore, UserStore, ChannelStore, @@ -571,34 +571,15 @@ class PerAccountStore extends PerAccountStoreBase with RealmStore get realmStore => _realm; final RealmStoreImpl _realm; - //|////////////////////////////// - // The realm's repertoire of available emoji. - - @override - EmojiDisplay emojiDisplayFor({ - required ReactionType emojiType, - required String emojiCode, - required String emojiName - }) { - return _emoji.emojiDisplayFor( - emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName); - } - - @override - Map>? get debugServerEmojiData => _emoji.debugServerEmojiData; - - @override - Iterable popularEmojiCandidates() => _emoji.popularEmojiCandidates(); - - @override - Iterable allEmojiCandidates() => _emoji.allEmojiCandidates(); - void setServerEmojiData(ServerEmojiData data) { _emoji.setServerEmojiData(data); notifyListeners(); } - EmojiStoreImpl _emoji; + @protected + @override + EmojiStore get emojiStore => _emoji; + final EmojiStoreImpl _emoji; //|////////////////////////////// // Data attached to the self-account on the realm. From d1b0812ee66289831df6a8e616fa10215161727d Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:40:40 -0700 Subject: [PATCH 387/423] user [nfc]: Provide RealmStore to UserStore --- lib/model/store.dart | 2 +- lib/model/user.dart | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index b3515f8137..9c9241ecd8 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -495,7 +495,7 @@ class PerAccountStore extends PerAccountStoreBase with savedSnippets: SavedSnippetStoreImpl(core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), typingNotifier: TypingNotifier(realm: realm), - users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot), + users: UserStoreImpl(realm: realm, initialSnapshot: initialSnapshot), typingStatus: TypingStatus(realm: realm), presence: Presence(realm: realm, initial: initialSnapshot.presences), diff --git a/lib/model/user.dart b/lib/model/user.dart index 13eb91f7d0..f994ce8007 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -4,10 +4,11 @@ import '../api/model/model.dart'; import 'algorithms.dart'; import 'localizations.dart'; import 'narrow.dart'; +import 'realm.dart'; import 'store.dart'; /// The portion of [PerAccountStore] describing the users in the realm. -mixin UserStore on PerAccountStoreBase { +mixin UserStore on PerAccountStoreBase, RealmStore { /// The user with the given ID, if that user is known. /// /// There may be other users that are perfectly real but are @@ -131,9 +132,9 @@ enum MutedUsersVisibilityEffect { /// Generally the only code that should need this class is [PerAccountStore] /// itself. Other code accesses this functionality through [PerAccountStore], /// or through the mixin [UserStore] which describes its interface. -class UserStoreImpl extends PerAccountStoreBase with UserStore { +class UserStoreImpl extends HasRealmStore with UserStore { UserStoreImpl({ - required super.core, + required super.realm, required InitialSnapshot initialSnapshot, }) : _users = Map.fromEntries( initialSnapshot.realmUsers From d71f081248ac8507859e3e3cc67379736d48a518 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:41:00 -0700 Subject: [PATCH 388/423] user [nfc]: Move hasPassedWaitingPeriod here --- lib/model/store.dart | 22 ---------------------- lib/model/user.dart | 22 ++++++++++++++++++++++ test/model/store_test.dart | 22 ---------------------- test/model/user_test.dart | 22 ++++++++++++++++++++++ 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 9c9241ecd8..1138fbad51 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -618,28 +618,6 @@ class PerAccountStore extends PerAccountStoreBase with final Presence presence; - /// Whether [user] has passed the realm's waiting period to be a full member. - /// - /// See: - /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member - /// - /// To determine if a user is a full member, callers must also check that the - /// user's role is at least [UserRole.member]. - bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) { - // [User.dateJoined] is in UTC. For logged-in users, the format is: - // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. - // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't - // include the timezone offset. In the later case, [DateTime.parse] will - // interpret it as the client's local timezone, which could lead to - // incorrect results; but that's acceptable for now because the app - // doesn't support viewing as a spectator. - // - // See the related discussion: - // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 - final dateJoined = DateTime.parse(user.dateJoined); - return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; - } - /// The user's real email address, if known, for displaying in the UI. /// /// Returns null if self-user isn't able to see the user's real email address, diff --git a/lib/model/user.dart b/lib/model/user.dart index f994ce8007..a0f51921fc 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -83,6 +83,28 @@ mixin UserStore on PerAccountStoreBase, RealmStore { return getUser(senderId)?.fullName ?? message.senderFullName; } + /// Whether [user] has passed the realm's waiting period to be a full member. + /// + /// See: + /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member + /// + /// To determine if a user is a full member, callers must also check that the + /// user's role is at least [UserRole.member]. + bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) { + // [User.dateJoined] is in UTC. For logged-in users, the format is: + // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. + // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't + // include the timezone offset. In the later case, [DateTime.parse] will + // interpret it as the client's local timezone, which could lead to + // incorrect results; but that's acceptable for now because the app + // doesn't support viewing as a spectator. + // + // See the related discussion: + // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 + final dateJoined = DateTime.parse(user.dateJoined); + return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; + } + /// Whether the user with [userId] is muted by the self-user. /// /// Looks for [userId] in a private [Set], diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 5ce42626cf..a0d22e4c6b 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -465,28 +465,6 @@ void main() { }); }); - group('PerAccountStore.hasPassedWaitingPeriod', () { - final store = eg.store(initialSnapshot: - eg.initialSnapshot(realmWaitingPeriodThreshold: 2)); - - final testCases = [ - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true), - ]; - - for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) { - test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} ' - 'passed waiting period by $currentDate', () { - final user = eg.user(dateJoined: dateJoined); - check(store.hasPassedWaitingPeriod(user, byDate: currentDate)) - .equals(hasPassedWaitingPeriod); - }); - } - }); - group('PerAccountStore.hasPostingPermission', () { final testCases = [ (ChannelPostPolicy.unknown, UserRole.unknown, true), diff --git a/test/model/user_test.dart b/test/model/user_test.dart index aa978ddb58..3638e3c214 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -58,6 +58,28 @@ void main() { }); }); + group('hasPassedWaitingPeriod', () { + final store = eg.store(initialSnapshot: + eg.initialSnapshot(realmWaitingPeriodThreshold: 2)); + + final testCases = [ + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true), + ]; + + for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) { + test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} ' + 'passed waiting period by $currentDate', () { + final user = eg.user(dateJoined: dateJoined); + check(store.hasPassedWaitingPeriod(user, byDate: currentDate)) + .equals(hasPassedWaitingPeriod); + }); + } + }); + group('RealmUserUpdateEvent', () { // TODO write more tests for handling RealmUserUpdateEvent From 9a5960aa2f869342d3295e0492e31cf60837a397 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:41:45 -0700 Subject: [PATCH 389/423] user [nfc]: Move userDisplayEmail here This doesn't move any tests, because this method has no tests. (Which at this point isn't a problem: for server versions we actually support, this method's logic becomes trivial.) --- lib/model/store.dart | 30 ------------------------------ lib/model/user.dart | 30 ++++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 1138fbad51..27f7370169 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -618,36 +618,6 @@ class PerAccountStore extends PerAccountStoreBase with final Presence presence; - /// The user's real email address, if known, for displaying in the UI. - /// - /// 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. - // Search for "delivery_email" in https://zulip.com/api/register-queue. - return user.deliveryEmail; - } else { - if (user.deliveryEmail != null) { - // A non-null value means self-user has access to [user]'s real email, - // while a null value doesn't necessarily mean it doesn't have access - // to the email, .... - return user.deliveryEmail; - } else if (emailAddressVisibility == EmailAddressVisibility.everyone) { - // ... we have to also check for [PerAccountStore.emailAddressVisibility]. - // See: - // * https://github.com/zulip/zulip-mobile/pull/5515#discussion_r997731727 - // * https://chat.zulip.org/#narrow/stream/378-api-design/topic/email.20address.20visibility/near/1296133 - return user.email; - } else { - return null; - } - } - } - //|////////////////////////////// // Streams, topics, and stuff about them. diff --git a/lib/model/user.dart b/lib/model/user.dart index a0f51921fc..4fa22e1dd5 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -83,6 +83,36 @@ mixin UserStore on PerAccountStoreBase, RealmStore { return getUser(senderId)?.fullName ?? message.senderFullName; } + /// The user's real email address, if known, for displaying in the UI. + /// + /// 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. + // Search for "delivery_email" in https://zulip.com/api/register-queue. + return user.deliveryEmail; + } else { + if (user.deliveryEmail != null) { + // A non-null value means self-user has access to [user]'s real email, + // while a null value doesn't necessarily mean it doesn't have access + // to the email, .... + return user.deliveryEmail; + } else if (emailAddressVisibility == EmailAddressVisibility.everyone) { + // ... we have to also check for [PerAccountStore.emailAddressVisibility]. + // See: + // * https://github.com/zulip/zulip-mobile/pull/5515#discussion_r997731727 + // * https://chat.zulip.org/#narrow/stream/378-api-design/topic/email.20address.20visibility/near/1296133 + return user.email; + } else { + return null; + } + } + } + /// Whether [user] has passed the realm's waiting period to be a full member. /// /// See: From 5233cfb2e1a4ca9539ad773a2eae6ac1e2d3634a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 21:07:51 -0700 Subject: [PATCH 390/423] user [nfc]: Move proxy boilerplate out to substore file --- lib/model/store.dart | 20 +++----------------- lib/model/user.dart | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 27f7370169..18a08dd1cf 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -441,7 +441,7 @@ class PerAccountStore extends PerAccountStoreBase with RealmStore, ProxyRealmStore, EmojiStore, ProxyEmojiStore, SavedSnippetStore, - UserStore, + UserStore, ProxyUserStore, ChannelStore, MessageStore { /// Construct a store for the user's data, starting from the given snapshot. @@ -595,23 +595,9 @@ class PerAccountStore extends PerAccountStoreBase with //|////////////////////////////// // Users and data about them. + @protected @override - User? getUser(int userId) => _users.getUser(userId); - - @override - Iterable get allUsers => _users.allUsers; - - @override - bool isUserMuted(int userId, {MutedUsersEvent? event}) => - _users.isUserMuted(userId, event: event); - - @override - MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) => - _users.mightChangeShouldMuteDmConversation(event); - - @override - UserStatus getUserStatus(int userId) => _users.getUserStatus(userId); - + UserStore get userStore => _users; final UserStoreImpl _users; final TypingStatus typingStatus; diff --git a/lib/model/user.dart b/lib/model/user.dart index 4fa22e1dd5..c40f79335a 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; @@ -179,6 +181,28 @@ enum MutedUsersVisibilityEffect { mixed; } +mixin ProxyUserStore on UserStore { + @protected + UserStore get userStore; + + @override + User? getUser(int userId) => userStore.getUser(userId); + + @override + Iterable get allUsers => userStore.allUsers; + + @override + bool isUserMuted(int userId, {MutedUsersEvent? event}) => + userStore.isUserMuted(userId, event: event); + + @override + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) => + userStore.mightChangeShouldMuteDmConversation(event); + + @override + UserStatus getUserStatus(int userId) => userStore.getUserStatus(userId); +} + /// The implementation of [UserStore] that does the work. /// /// Generally the only code that should need this class is [PerAccountStore] From 623bcb46b44fb9b907df2dd8b1e16b945a260f63 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 16:46:10 -0700 Subject: [PATCH 391/423] user [nfc]: Add HasUserStore for other substores to use Like HasRealmStore for data about the realm, this will help make it convenient for other substores to refer to data about users. --- lib/model/user.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/model/user.dart b/lib/model/user.dart index c40f79335a..aa4666c359 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -11,6 +11,9 @@ import 'store.dart'; /// The portion of [PerAccountStore] describing the users in the realm. mixin UserStore on PerAccountStoreBase, RealmStore { + @protected + RealmStore get realmStore; + /// The user with the given ID, if that user is known. /// /// There may be other users that are perfectly real but are @@ -203,6 +206,17 @@ mixin ProxyUserStore on UserStore { UserStatus getUserStatus(int userId) => userStore.getUserStatus(userId); } +/// A base class for [PerAccountStore] substores that need access to [UserStore] +/// as well as to its prerequisites [CorePerAccountStore] and [RealmStore]. +abstract class HasUserStore extends HasRealmStore with UserStore, ProxyUserStore { + HasUserStore({required UserStore users}) + : userStore = users, super(realm: users.realmStore); + + @protected + @override + final UserStore userStore; +} + /// The implementation of [UserStore] that does the work. /// /// Generally the only code that should need this class is [PerAccountStore] From fab85ca1e87a567797533b4dcc525ee987a4a6df Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:47:39 -0700 Subject: [PATCH 392/423] channel [nfc]: Provide UserStore to ChannelStore This way, this substore becomes a valid home for methods that need to refer to data about both channels and users. --- lib/model/channel.dart | 13 ++++++++++--- lib/model/store.dart | 6 ++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 4dbc61756e..56f42ec7d0 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import 'store.dart'; +import 'user.dart'; /// The portion of [PerAccountStore] for channels, topics, and stuff about them. /// @@ -12,7 +14,7 @@ import '../api/model/model.dart'; /// implementation of [PerAccountStore], to avoid circularity. /// /// The data structures described here are implemented at [ChannelStoreImpl]. -mixin ChannelStore { +mixin ChannelStore on UserStore { /// All known channels/streams, indexed by [ZulipStream.streamId]. /// /// The same [ZulipStream] objects also appear in [streamsByName]. @@ -165,8 +167,11 @@ enum UserTopicVisibilityEffect { /// Generally the only code that should need this class is [PerAccountStore] /// itself. Other code accesses this functionality through [PerAccountStore], /// or through the mixin [ChannelStore] which describes its interface. -class ChannelStoreImpl with ChannelStore { - factory ChannelStoreImpl({required InitialSnapshot initialSnapshot}) { +class ChannelStoreImpl extends HasUserStore with ChannelStore { + factory ChannelStoreImpl({ + required UserStore users, + required InitialSnapshot initialSnapshot, + }) { final subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map( (subscription) => MapEntry(subscription.streamId, subscription))); @@ -186,6 +191,7 @@ class ChannelStoreImpl with ChannelStore { } return ChannelStoreImpl._( + users: users, streams: streams, streamsByName: streams.map((_, stream) => MapEntry(stream.name, stream)), subscriptions: subscriptions, @@ -194,6 +200,7 @@ class ChannelStoreImpl with ChannelStore { } ChannelStoreImpl._({ + required super.users, required this.streams, required this.streamsByName, required this.subscriptions, diff --git a/lib/model/store.dart b/lib/model/store.dart index 18a08dd1cf..309843db4c 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -482,8 +482,10 @@ class PerAccountStore extends PerAccountStoreBase with accountId: accountId, selfUserId: account.userId, ); - final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot); final realm = RealmStoreImpl(core: core, initialSnapshot: initialSnapshot); + final users = UserStoreImpl(realm: realm, initialSnapshot: initialSnapshot); + final channels = ChannelStoreImpl(users: users, + initialSnapshot: initialSnapshot); return PerAccountStore._( core: core, groups: UserGroupStoreImpl(core: core, @@ -495,7 +497,7 @@ class PerAccountStore extends PerAccountStoreBase with savedSnippets: SavedSnippetStoreImpl(core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), typingNotifier: TypingNotifier(realm: realm), - users: UserStoreImpl(realm: realm, initialSnapshot: initialSnapshot), + users: users, typingStatus: TypingStatus(realm: realm), presence: Presence(realm: realm, initial: initialSnapshot.presences), From 6b47ff087d47c97e31724160ad9ebbde7be6ebca Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:48:33 -0700 Subject: [PATCH 393/423] channel [nfc]: Move hasPostingPermission here --- lib/model/channel.dart | 24 ++++++++++++ lib/model/store.dart | 24 ------------ test/model/channel_test.dart | 76 ++++++++++++++++++++++++++++++++++++ test/model/store_test.dart | 76 ------------------------------------ 4 files changed, 100 insertions(+), 100 deletions(-) diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 56f42ec7d0..908f84414f 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -138,6 +138,30 @@ mixin ChannelStore on UserStore { return true; } } + + bool hasPostingPermission({ + required ZulipStream inChannel, + required User user, + required DateTime byDate, + }) { + final role = user.role; + // We let the users with [unknown] role to send the message, then the server + // will decide to accept it or not based on its actual role. + if (role == UserRole.unknown) return true; + + switch (inChannel.channelPostPolicy) { + case ChannelPostPolicy.any: return true; + case ChannelPostPolicy.fullMembers: { + if (!role.isAtLeast(UserRole.member)) return false; + return role == UserRole.member + ? hasPassedWaitingPeriod(user, byDate: byDate) + : true; + } + case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); + case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator); + case ChannelPostPolicy.unknown: return true; + } + } } /// Whether and how a given [UserTopicEvent] will affect the results diff --git a/lib/model/store.dart b/lib/model/store.dart index 309843db4c..5233ca0c8c 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -624,30 +624,6 @@ class PerAccountStore extends PerAccountStoreBase with final ChannelStoreImpl _channels; - bool hasPostingPermission({ - required ZulipStream inChannel, - required User user, - required DateTime byDate, - }) { - final role = user.role; - // We let the users with [unknown] role to send the message, then the server - // will decide to accept it or not based on its actual role. - if (role == UserRole.unknown) return true; - - switch (inChannel.channelPostPolicy) { - case ChannelPostPolicy.any: return true; - case ChannelPostPolicy.fullMembers: { - if (!role.isAtLeast(UserRole.member)) return false; - return role == UserRole.member - ? hasPassedWaitingPeriod(user, byDate: byDate) - : true; - } - case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); - case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator); - case ChannelPostPolicy.unknown: return true; - } - } - //|////////////////////////////// // Messages, and summaries of messages. diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 0fd83f64f9..07e542e6a1 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -455,6 +455,82 @@ void main() { }); }); + group('hasPostingPermission', () { + final testCases = [ + (ChannelPostPolicy.unknown, UserRole.unknown, true), + (ChannelPostPolicy.unknown, UserRole.guest, true), + (ChannelPostPolicy.unknown, UserRole.member, true), + (ChannelPostPolicy.unknown, UserRole.moderator, true), + (ChannelPostPolicy.unknown, UserRole.administrator, true), + (ChannelPostPolicy.unknown, UserRole.owner, true), + (ChannelPostPolicy.any, UserRole.unknown, true), + (ChannelPostPolicy.any, UserRole.guest, true), + (ChannelPostPolicy.any, UserRole.member, true), + (ChannelPostPolicy.any, UserRole.moderator, true), + (ChannelPostPolicy.any, UserRole.administrator, true), + (ChannelPostPolicy.any, UserRole.owner, true), + (ChannelPostPolicy.fullMembers, UserRole.unknown, true), + (ChannelPostPolicy.fullMembers, UserRole.guest, false), + // The fullMembers/member case gets its own tests further below. + // (ChannelPostPolicy.fullMembers, UserRole.member, /* complicated */), + (ChannelPostPolicy.fullMembers, UserRole.moderator, true), + (ChannelPostPolicy.fullMembers, UserRole.administrator, true), + (ChannelPostPolicy.fullMembers, UserRole.owner, true), + (ChannelPostPolicy.moderators, UserRole.unknown, true), + (ChannelPostPolicy.moderators, UserRole.guest, false), + (ChannelPostPolicy.moderators, UserRole.member, false), + (ChannelPostPolicy.moderators, UserRole.moderator, true), + (ChannelPostPolicy.moderators, UserRole.administrator, true), + (ChannelPostPolicy.moderators, UserRole.owner, true), + (ChannelPostPolicy.administrators, UserRole.unknown, true), + (ChannelPostPolicy.administrators, UserRole.guest, false), + (ChannelPostPolicy.administrators, UserRole.member, false), + (ChannelPostPolicy.administrators, UserRole.moderator, false), + (ChannelPostPolicy.administrators, UserRole.administrator, true), + (ChannelPostPolicy.administrators, UserRole.owner, true), + ]; + + for (final (ChannelPostPolicy policy, UserRole role, bool canPost) in testCases) { + test('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel ' + 'with "${policy.name}" policy', () { + final store = eg.store(); + final actual = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role), + // [byDate] is not actually relevant for these test cases; for the + // ones which it is, they're practiced below. + byDate: DateTime.now()); + check(actual).equals(canPost); + }); + } + + group('"member" user posting in a channel with "fullMembers" policy', () { + PerAccountStore localStore({required int realmWaitingPeriodThreshold}) => + eg.store(initialSnapshot: eg.initialSnapshot( + realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); + + User memberUser({required String dateJoined}) => eg.user( + role: UserRole.member, dateJoined: dateJoined); + + test('a "full" member -> can post in the channel', () { + final store = localStore(realmWaitingPeriodThreshold: 3); + final hasPermission = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), + user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), + byDate: DateTime.utc(2024, 11, 28, 10, 00)); + check(hasPermission).isTrue(); + }); + + test('not a "full" member -> cannot post in the channel', () { + final store = localStore(realmWaitingPeriodThreshold: 3); + final actual = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), + user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), + byDate: DateTime.utc(2024, 11, 28, 09, 59)); + check(actual).isFalse(); + }); + }); + }); + group('makeTopicKeyedMap', () { test('"a" equals "A"', () { final map = makeTopicKeyedMap() diff --git a/test/model/store_test.dart b/test/model/store_test.dart index a0d22e4c6b..dc22a12bc3 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -465,82 +465,6 @@ void main() { }); }); - group('PerAccountStore.hasPostingPermission', () { - final testCases = [ - (ChannelPostPolicy.unknown, UserRole.unknown, true), - (ChannelPostPolicy.unknown, UserRole.guest, true), - (ChannelPostPolicy.unknown, UserRole.member, true), - (ChannelPostPolicy.unknown, UserRole.moderator, true), - (ChannelPostPolicy.unknown, UserRole.administrator, true), - (ChannelPostPolicy.unknown, UserRole.owner, true), - (ChannelPostPolicy.any, UserRole.unknown, true), - (ChannelPostPolicy.any, UserRole.guest, true), - (ChannelPostPolicy.any, UserRole.member, true), - (ChannelPostPolicy.any, UserRole.moderator, true), - (ChannelPostPolicy.any, UserRole.administrator, true), - (ChannelPostPolicy.any, UserRole.owner, true), - (ChannelPostPolicy.fullMembers, UserRole.unknown, true), - (ChannelPostPolicy.fullMembers, UserRole.guest, false), - // The fullMembers/member case gets its own tests further below. - // (ChannelPostPolicy.fullMembers, UserRole.member, /* complicated */), - (ChannelPostPolicy.fullMembers, UserRole.moderator, true), - (ChannelPostPolicy.fullMembers, UserRole.administrator, true), - (ChannelPostPolicy.fullMembers, UserRole.owner, true), - (ChannelPostPolicy.moderators, UserRole.unknown, true), - (ChannelPostPolicy.moderators, UserRole.guest, false), - (ChannelPostPolicy.moderators, UserRole.member, false), - (ChannelPostPolicy.moderators, UserRole.moderator, true), - (ChannelPostPolicy.moderators, UserRole.administrator, true), - (ChannelPostPolicy.moderators, UserRole.owner, true), - (ChannelPostPolicy.administrators, UserRole.unknown, true), - (ChannelPostPolicy.administrators, UserRole.guest, false), - (ChannelPostPolicy.administrators, UserRole.member, false), - (ChannelPostPolicy.administrators, UserRole.moderator, false), - (ChannelPostPolicy.administrators, UserRole.administrator, true), - (ChannelPostPolicy.administrators, UserRole.owner, true), - ]; - - for (final (ChannelPostPolicy policy, UserRole role, bool canPost) in testCases) { - test('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel ' - 'with "${policy.name}" policy', () { - final store = eg.store(); - final actual = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role), - // [byDate] is not actually relevant for these test cases; for the - // ones which it is, they're practiced below. - byDate: DateTime.now()); - check(actual).equals(canPost); - }); - } - - group('"member" user posting in a channel with "fullMembers" policy', () { - PerAccountStore localStore({required int realmWaitingPeriodThreshold}) => - eg.store(initialSnapshot: eg.initialSnapshot( - realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); - - User memberUser({required String dateJoined}) => eg.user( - role: UserRole.member, dateJoined: dateJoined); - - test('a "full" member -> can post in the channel', () { - final store = localStore(realmWaitingPeriodThreshold: 3); - final hasPermission = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), - user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), - byDate: DateTime.utc(2024, 11, 28, 10, 00)); - check(hasPermission).isTrue(); - }); - - test('not a "full" member -> cannot post in the channel', () { - final store = localStore(realmWaitingPeriodThreshold: 3); - final actual = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), - user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), - byDate: DateTime.utc(2024, 11, 28, 09, 59)); - check(actual).isFalse(); - }); - }); - }); - group('PerAccountStore.handleEvent', () { // Mostly this method just dispatches to ChannelStore and MessageStore etc., // and so its tests generally live in the test files for those From 72cf5d65ca473ffb76210a6daabac57dede58afd Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Apr 2025 16:16:03 -0700 Subject: [PATCH 394/423] channel [nfc]: Move proxy boilerplate out to substore file --- lib/model/channel.dart | 22 ++++++++++++++++++++++ lib/model/store.dart | 16 +++------------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 908f84414f..3a214e2032 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -186,6 +186,28 @@ enum UserTopicVisibilityEffect { } } +mixin ProxyChannelStore on ChannelStore { + @protected + ChannelStore get channelStore; + + @override + Map get streams => channelStore.streams; + + @override + Map get streamsByName => channelStore.streamsByName; + + @override + Map get subscriptions => channelStore.subscriptions; + + @override + UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => + channelStore.topicVisibilityPolicy(streamId, topic); + + @override + Map> get debugTopicVisibility => + channelStore.debugTopicVisibility; +} + /// The implementation of [ChannelStore] that does the work. /// /// Generally the only code that should need this class is [PerAccountStore] diff --git a/lib/model/store.dart b/lib/model/store.dart index 5233ca0c8c..683611763b 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -442,7 +442,7 @@ class PerAccountStore extends PerAccountStoreBase with EmojiStore, ProxyEmojiStore, SavedSnippetStore, UserStore, ProxyUserStore, - ChannelStore, + ChannelStore, ProxyChannelStore, MessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// @@ -609,19 +609,9 @@ class PerAccountStore extends PerAccountStoreBase with //|////////////////////////////// // Streams, topics, and stuff about them. + @protected @override - Map get streams => _channels.streams; - @override - Map get streamsByName => _channels.streamsByName; - @override - Map get subscriptions => _channels.subscriptions; - @override - UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => - _channels.topicVisibilityPolicy(streamId, topic); - @override - Map> get debugTopicVisibility => - _channels.debugTopicVisibility; - + ChannelStore get channelStore => _channels; final ChannelStoreImpl _channels; //|////////////////////////////// From f83c9c104798135e1cbc1a493aa2e88f00343437 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 14:42:49 -0700 Subject: [PATCH 395/423] message test [nfc]: Move sendMessage smoke test to follow sendMessage Ideally this would have been done when sendMessage itself was moved, in 942aa87a5 (#1455), oops. --- test/model/message_test.dart | 23 +++++++++++++++++++++++ test/model/store_test.dart | 26 -------------------------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 5d8b340a66..69f349c02d 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -118,6 +118,29 @@ void main() { })); group('sendMessage', () { + test('smoke', () async { + 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()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('world')), + content: 'hello'); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields.deepEquals({ + 'type': 'stream', + 'to': stream.streamId.toString(), + 'topic': 'world', + 'content': 'hello', + 'read_by_sender': 'true', + 'queue_id': 'fb67bf8a-c031-47cc-84cf-ed80accacda8', + 'local_id': store.outboxMessages.keys.single.toString(), + }); + }); + final stream = eg.stream(); final streamDestination = StreamDestination(stream.streamId, eg.t('some topic')); late StreamMessage message; diff --git a/test/model/store_test.dart b/test/model/store_test.dart index dc22a12bc3..240f2cbffd 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -13,7 +13,6 @@ 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/route/events.dart'; -import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/realm.dart'; import 'package:zulip/log.dart'; import 'package:zulip/model/actions.dart'; @@ -471,31 +470,6 @@ void main() { // (but they call the handleEvent method because it's the entry point). }); - group('PerAccountStore.sendMessage', () { - test('smoke', () async { - 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()); - await store.sendMessage( - destination: StreamDestination(stream.streamId, eg.t('world')), - content: 'hello'); - check(connection.takeRequests()).single.isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages') - ..bodyFields.deepEquals({ - 'type': 'stream', - 'to': stream.streamId.toString(), - 'topic': 'world', - 'content': 'hello', - 'read_by_sender': 'true', - 'queue_id': 'fb67bf8a-c031-47cc-84cf-ed80accacda8', - 'local_id': store.outboxMessages.keys.single.toString(), - }); - }); - }); - group('UpdateMachine.load', () { late TestGlobalStore globalStore; late FakeApiConnection connection; From 9499dbe617f79d2e041acfabe3a0ba068d4541e6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 00:58:43 -0700 Subject: [PATCH 396/423] message [nfc]: Consolidate "disposed" checks onto substore methods For most of these methods, we check the store isn't disposed within the substore's implementation; for a few, we check in the proxy implementation. Simplify the proxies by always checking in the substore's implementation. --- lib/model/message.dart | 6 ++++-- lib/model/store.dart | 4 ---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 5d4e367d62..ac4a145523 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -278,8 +278,10 @@ class MessageStoreImpl extends HasRealmStore with MessageStore, _OutboxMessageSt } @override - bool? getEditMessageErrorStatus(int messageId) => - _editMessageRequests[messageId]?.hasError; + bool? getEditMessageErrorStatus(int messageId) { + assert(!_disposed); + return _editMessageRequests[messageId]?.hasError; + } final Map _editMessageRequests = {}; diff --git a/lib/model/store.dart b/lib/model/store.dart index 683611763b..7aa78acb81 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -632,7 +632,6 @@ class PerAccountStore extends PerAccountStoreBase with _messages.markReadFromScroll(messageIds); @override Future sendMessage({required MessageDestination destination, required String content}) { - assert(!_disposed); return _messages.sendMessage(destination: destination, content: content); } @override @@ -646,7 +645,6 @@ class PerAccountStore extends PerAccountStoreBase with } @override bool? getEditMessageErrorStatus(int messageId) { - assert(!_disposed); return _messages.getEditMessageErrorStatus(messageId); } @override @@ -655,13 +653,11 @@ class PerAccountStore extends PerAccountStoreBase with required String originalRawContent, required String newContent, }) { - assert(!_disposed); return _messages.editMessage(messageId: messageId, originalRawContent: originalRawContent, newContent: newContent); } @override ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { - assert(!_disposed); return _messages.takeFailedMessageEdit(messageId); } From 40552166794ce5f9298eccb1cb26916bfa7c1556 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 6 Jul 2025 15:26:20 -0700 Subject: [PATCH 397/423] message [nfc]: Drop reconcileMessages from main interface Much like we did with setServerEmojiData in a recent commit, and for the same reasons: after setServerEmojiData, this was the only one of our numerous methods on the various FooStore interfaces where the two different implementations of the method actually had different behavior. That's potentially misleading, so make the two implementations be unrelated methods instead. --- lib/model/message.dart | 18 ------------------ lib/model/store.dart | 20 +++++++++++++++++++- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index ac4a145523..85e277c686 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -46,23 +46,6 @@ mixin MessageStore { /// or [OutboxMessageState.waitPeriodExpired]. OutboxMessage takeOutboxMessage(int localMessageId); - /// Reconcile a batch of just-fetched messages with the store, - /// mutating the list. - /// - /// This is called after a [getMessages] request to report the result - /// to the store. - /// - /// The list's length will not change, but some entries may be replaced - /// by a different [Message] object with the same [Message.id], - /// or mutated to remove [Message.matchContent] and [Message.matchTopic] - /// (since these are appropriate for search views but not the central store). - /// All [Message] objects in the resulting list will be present in - /// [this.messages]. - /// - /// [Message.matchTopic] and [Message.matchContent] should be captured, - /// as needed for search, before this is called. - 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. @@ -249,7 +232,6 @@ class MessageStoreImpl extends HasRealmStore with MessageStore, _OutboxMessageSt return _outboxSendMessage(destination: destination, content: content); } - @override void reconcileMessages(List messages) { assert(!_disposed); // What to do when some of the just-fetched messages are already known? diff --git a/lib/model/store.dart b/lib/model/store.dart index 7aa78acb81..27e325d5be 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -637,12 +637,30 @@ class PerAccountStore extends PerAccountStoreBase with @override OutboxMessage takeOutboxMessage(int localMessageId) => _messages.takeOutboxMessage(localMessageId); - @override + + /// Reconcile a batch of just-fetched messages with the store, + /// mutating the list. + /// + /// This is called after a [getMessages] request to report the result + /// to the store. + /// + /// The list's length will not change, but some entries may be replaced + /// by a different [Message] object with the same [Message.id], + /// and the store will also be updated. + /// When this method returns, all [Message] objects in the list + /// will be present in the map `this.messages`. + /// + /// The list entries may be mutated to remove + /// [Message.matchContent] and [Message.matchTopic] + /// (since these are appropriate for search views but not the central store). + /// The values of those fields should therefore be captured, + /// as needed for search, before this is called. void reconcileMessages(List messages) { _messages.reconcileMessages(messages); // TODO(#649) notify [unreads] of the just-fetched messages // TODO(#650) notify [recentDmConversationsView] of the just-fetched messages } + @override bool? getEditMessageErrorStatus(int messageId) { return _messages.getEditMessageErrorStatus(messageId); From 2b0944f8f7435ad1d75210f27a2437f744f3f57f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Jul 2025 01:03:44 -0700 Subject: [PATCH 398/423] message [nfc]: Move proxy boilerplate out to substore file --- lib/model/message.dart | 47 ++++++++++++++++++++++++++++++++++++++++++ lib/model/store.dart | 47 +++--------------------------------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 85e277c686..24788bdd1e 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -74,6 +74,53 @@ mixin MessageStore { ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId); } +mixin ProxyMessageStore on MessageStore { + @protected + MessageStore get messageStore; + + @override + Map get messages => messageStore.messages; + @override + Map get outboxMessages => messageStore.outboxMessages; + @override + void registerMessageList(MessageListView view) => + messageStore.registerMessageList(view); + @override + void unregisterMessageList(MessageListView view) => + messageStore.unregisterMessageList(view); + @override + void markReadFromScroll(Iterable messageIds) => + messageStore.markReadFromScroll(messageIds); + @override + Future sendMessage({required MessageDestination destination, required String content}) { + return messageStore.sendMessage(destination: destination, content: content); + } + @override + OutboxMessage takeOutboxMessage(int localMessageId) => + messageStore.takeOutboxMessage(localMessageId); + + @override + bool? getEditMessageErrorStatus(int messageId) { + return messageStore.getEditMessageErrorStatus(messageId); + } + @override + void editMessage({ + required int messageId, + required String originalRawContent, + required String newContent, + }) { + return messageStore.editMessage(messageId: messageId, + originalRawContent: originalRawContent, newContent: newContent); + } + @override + ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { + return messageStore.takeFailedMessageEdit(messageId); + } + + @override + Set get debugMessageListViews => messageStore.debugMessageListViews; +} + class _EditMessageRequestStatus { _EditMessageRequestStatus({ required this.hasError, diff --git a/lib/model/store.dart b/lib/model/store.dart index 27e325d5be..03ff13022a 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -14,7 +14,6 @@ import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/events.dart'; -import '../api/route/messages.dart'; import '../api/backoff.dart'; import '../api/route/realm.dart'; import '../log.dart'; @@ -25,7 +24,6 @@ import 'database.dart'; import 'emoji.dart'; import 'localizations.dart'; import 'message.dart'; -import 'message_list.dart'; import 'presence.dart'; import 'realm.dart'; import 'recent_dm_conversations.dart'; @@ -443,7 +441,7 @@ class PerAccountStore extends PerAccountStoreBase with SavedSnippetStore, UserStore, ProxyUserStore, ChannelStore, ProxyChannelStore, - MessageStore { + MessageStore, ProxyMessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// /// The global store must already have been updated with @@ -617,27 +615,6 @@ class PerAccountStore extends PerAccountStoreBase with //|////////////////////////////// // Messages, and summaries of messages. - @override - Map get messages => _messages.messages; - @override - Map get outboxMessages => _messages.outboxMessages; - @override - void registerMessageList(MessageListView view) => - _messages.registerMessageList(view); - @override - void unregisterMessageList(MessageListView view) => - _messages.unregisterMessageList(view); - @override - void markReadFromScroll(Iterable messageIds) => - _messages.markReadFromScroll(messageIds); - @override - Future sendMessage({required MessageDestination destination, required String content}) { - return _messages.sendMessage(destination: destination, content: content); - } - @override - OutboxMessage takeOutboxMessage(int localMessageId) => - _messages.takeOutboxMessage(localMessageId); - /// Reconcile a batch of just-fetched messages with the store, /// mutating the list. /// @@ -661,27 +638,9 @@ class PerAccountStore extends PerAccountStoreBase with // TODO(#650) notify [recentDmConversationsView] of the just-fetched messages } + @protected @override - bool? getEditMessageErrorStatus(int messageId) { - return _messages.getEditMessageErrorStatus(messageId); - } - @override - void editMessage({ - required int messageId, - required String originalRawContent, - required String newContent, - }) { - return _messages.editMessage(messageId: messageId, - originalRawContent: originalRawContent, newContent: newContent); - } - @override - ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId) { - return _messages.takeFailedMessageEdit(messageId); - } - - @override - Set get debugMessageListViews => _messages.debugMessageListViews; - + MessageStore get messageStore => _messages; final MessageStoreImpl _messages; final Unreads unreads; From 8c3b5057da0d0b3ff0147c69473028cd5f09233d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 12:00:07 -0700 Subject: [PATCH 399/423] api [nfc]: Remove a comment about event_types that came from outdated doc The doc for message_details on this event says the field is present if the flag is "read", with no mention of event_types. Looking through zulip/zulip's Git history, it turns out it had been mentioned in the past, but the mention was removed, in zulip/zulip@48a1cf04d: - Present if `message` and `update_message_flags` are both present in - `event_types` and the `flag` is `read` and the `op` is `remove`. + Only present if the specified `flag` is `"read"`. So the comment (and no-op `&& true`) isn't necessary; remove it. We noticed this because the analyzer started flagging an info-level `no_literal_bool_comparisons` here. --- lib/api/model/events.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 7189cd28ef..bb3d7245e0 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -1172,10 +1172,7 @@ class UpdateMessageFlagsRemoveEvent extends UpdateMessageFlagsEvent { factory UpdateMessageFlagsRemoveEvent.fromJson(Map json) { final result = _$UpdateMessageFlagsRemoveEventFromJson(json); // Crunchy-shell validation - if ( - result.flag == MessageFlag.read - && true // (we assume `event_types` has `message` and `update_message_flags`) - ) { + if (result.flag == MessageFlag.read) { result.messageDetails as Map; } return result; From 0ce94c45f1951275734953ee795651241ec99fc3 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 23 Jul 2025 16:15:47 -0700 Subject: [PATCH 400/423] analyze: Fix new lint on latest Flutter main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flutter analyze` has started giving the following, which breaks CI: info • Unnecessary comparison to a boolean literal • lib/api/model/events.dart:1177:10 • no_literal_bool_comparisons info • Unnecessary comparison to a boolean literal • lib/model/channel.dart:158:13 • no_literal_bool_comparisons We fixed the first one in the previous commit; this fixes the second. See discussion: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/CI.20on.20latest.20upstream/near/2228858 --- lib/model/channel.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 3a214e2032..7a10d52f6b 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -153,9 +153,10 @@ mixin ChannelStore on UserStore { case ChannelPostPolicy.any: return true; case ChannelPostPolicy.fullMembers: { if (!role.isAtLeast(UserRole.member)) return false; - return role == UserRole.member - ? hasPassedWaitingPeriod(user, byDate: byDate) - : true; + if (role == UserRole.member) { + return hasPassedWaitingPeriod(user, byDate: byDate); + } + return true; } case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator); From d13e3d9bb0ff35825cdf722f28d0e73489117d76 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 24 Jul 2025 09:19:15 -0700 Subject: [PATCH 401/423] new-dm test: Split out muted-user checks as own test case This lets us make it a bit more thorough, while not adding to the complexity of reading the test case for the basic logic. --- test/widgets/new_dm_sheet_test.dart | 30 ++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index ae77e9643f..6850ef32cb 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -116,24 +116,19 @@ void main() { }); group('user filtering', () { - final mutedUser = eg.user(fullName: 'Someone Muted'); final testUsers = [ eg.user(fullName: 'Alice Anderson'), eg.user(fullName: 'Bob Brown'), eg.user(fullName: 'Charlie Carter'), - mutedUser, ]; - testWidgets('shows all non-muted users initially', (tester) async { - await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]); + testWidgets('shows full list initially', (tester) async { + await setupSheet(tester, users: testUsers); check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); check(findText(includePlaceholders: false, 'Bob Brown')).findsOne(); check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); - check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); - check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); - check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); }); testWidgets('shows filtered users based on search', (tester) async { @@ -145,6 +140,27 @@ void main() { check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); }); + testWidgets('muted users excluded', (tester) async { + // Omit muted users both before there's a query… + final mutedUser = eg.user(fullName: 'Someone Muted'); + await setupSheet(tester, + users: [...testUsers, mutedUser], mutedUserIds: [mutedUser.userId]); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); + + // … and after a query. One which matches both the user's actual name and + // the replacement text "Muted user", for good measure. + await tester.enterText(find.byType(TextField), 'e'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(2); + }); + // TODO test sorting by recent-DMs // TODO test that scroll position resets on query change From ff335a64c23ca1a82ac8676e4c886a9fea1cae00 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 24 Jul 2025 09:13:07 -0700 Subject: [PATCH 402/423] new-dm: Exclude deactivated users Fixes #1743. --- lib/widgets/new_dm_sheet.dart | 7 +++---- test/widgets/new_dm_sheet_test.dart | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index 682adea91c..93fbf5c354 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -1,4 +1,3 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; @@ -69,9 +68,9 @@ class _NewDmPickerState extends State with PerAccountStoreAwareStat } void _initSortedUsers(PerAccountStore store) { - final sansMuted = store.allUsers - .whereNot((user) => store.isUserMuted(user.userId)); - sortedUsers = List.from(sansMuted) + final users = store.allUsers + .where((user) => user.isActive && !store.isUserMuted(user.userId)); + sortedUsers = List.from(users) ..sort((a, b) => MentionAutocompleteView.compareByDms(a, b, store: store)); _updateFilteredUsers(store); } diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index 6850ef32cb..a95d42dc1a 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -140,6 +140,22 @@ void main() { check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); }); + testWidgets('deactivated users excluded', (tester) async { + // Omit a deactivated user both before there's a query… + final deactivatedUser = eg.user(fullName: 'Impostor Charlie', isActive: false); + await setupSheet(tester, users: [...testUsers, deactivatedUser]); + check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); + + // … and after a query that would match their name. + await tester.enterText(find.byType(TextField), 'Charlie'); + await tester.pump(); + check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(1); + }); + testWidgets('muted users excluded', (tester) async { // Omit muted users both before there's a query… final mutedUser = eg.user(fullName: 'Someone Muted'); From a1246eea2f5d139445fd350161e2e0f25f39c84f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 24 Jul 2025 09:32:36 -0700 Subject: [PATCH 403/423] user [nfc]: Document explicitly that allUsers includes deactivated Potentially this would have helped us avoid the issue #1743. --- lib/model/user.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/model/user.dart b/lib/model/user.dart index aa4666c359..7d195e3197 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -35,7 +35,10 @@ mixin UserStore on PerAccountStoreBase, RealmStore { /// Consider using [userDisplayName]. User? getUser(int userId); - /// All known users in the realm. + /// All known users in the realm, including deactivated users. + /// + /// Before presenting these users in the UI, consider whether to exclude + /// users who are deactivated (see [User.isActive]) or muted ([isUserMuted]). /// /// This may have a large number of elements, like tens of thousands. /// Consider [getUser] or other alternatives to iterating through this. From 2fc55abbe1aab3ac7c1ebb10d4b7bbb7577f09a2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 23 Jul 2025 18:29:47 -0700 Subject: [PATCH 404/423] lightbox test: Skip a no-hero test, pending rework for new page transitions This test relies on background assumptions about how the "back" page transition work which are true of the current default transitions on Android (the default target platform of tests), but are broken by the upcoming introduction of predictive back: https://github.com/flutter/flutter/pull/165832#issuecomment-3111641360 For a quick unblocking of the upstream change, skip the test. In principle there's no reason a test like this can't work with the upcoming upstream behavior; it'll just need to work a little differently. But leave that for a follow-up change. --- test/widgets/lightbox_test.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index dbeb06a3fd..fc013173e7 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -275,6 +275,7 @@ void main() { }); testWidgets('no hero animation occurs between different message list pages for same image', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/930 Rect getElementRect(Element element) => tester.getRect(find.byElementPredicate((e) => e == element)); @@ -310,7 +311,13 @@ void main() { } debugNetworkImageHttpClientProvider = null; - }); + }, skip: true, // TODO get this no-hero test to work again with new page transitions; + // see https://github.com/flutter/flutter/pull/165832#issuecomment-3111641360 . + // Perhaps specify the old default, of ZoomPageTransitionsBuilder? + // Or make getElementRect work relative to the enclosing page, + // rather than the whole screen, so that the test becomes robust to + // the whole pages moving around. + ); }); group('_ImageLightboxPage', () { From 55d055f03f0a01f10379aa4e82e6a757b805d75f Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 24 Jul 2025 01:26:36 +0430 Subject: [PATCH 405/423] deps: Upgrade Flutter to 3.33.0-1.0.pre-1086 And update Flutter's supporting libraries to match. --- pubspec.lock | 116 +++++++++++++++++++++++++-------------------------- pubspec.yaml | 4 +- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index e693e77f23..5537bd5c74 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,18 +13,18 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: a5788040810bd84400bc209913fbc40f388cded7cdf95ee2f5d2bff7e38d5241 + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 url: "https://pub.dev" source: hosted - version: "1.3.58" + version: "1.3.59" analyzer: dependency: transitive description: name: analyzer - sha256: "01949bf52ad33f0e0f74f881fbaac4f348c556531951d92c8d16f1262aa19ff8" + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "7.5.4" + version: "7.7.1" app_settings: dependency: "direct main" description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: build - sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + sha256: "7d95cbbb1526ab5ae977df9b4cc660963b9b27f6d1075c0b34653868911385e4" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "3.0.0" build_config: dependency: transitive description: @@ -85,26 +85,26 @@ packages: dependency: transitive description: name: build_resolvers - sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + sha256: "38c9c339333a09b090a638849a4c56e70a404c6bdd3b511493addfbc113b60c2" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "3.0.0" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + sha256: b971d4a1c789eba7be3e6fe6ce5e5b50fd3719e3cb485b3fad6d04358304351d url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.6.0" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + sha256: c04e612ca801cd0928ccdb891c263a2b1391cb27940a5ea5afcf9ba894de5d62 url: "https://pub.dev" source: hosted - version: "9.1.2" + version: "9.2.0" built_collection: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" + sha256: "0b1b12a0a549605e5f04476031cd0bc91ead1d7c8e830773a18ee54179b3cb62" url: "https://pub.dev" source: hosted - version: "8.10.1" + version: "8.11.0" characters: dependency: transitive description: @@ -214,10 +214,10 @@ packages: dependency: transitive description: name: coverage - sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" url: "https://pub.dev" source: hosted - version: "1.14.1" + version: "1.15.0" cross_file: dependency: transitive description: @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.1" dbus: dependency: transitive description: @@ -278,18 +278,18 @@ packages: dependency: "direct main" description: name: drift - sha256: e60c715f045dd33624fc533efb0075e057debec9f39e83843e518f488a0e21fb + sha256: dce2723fb0dd03563af21f305f8f96514c27f870efba934b4fe84d4fedb4eff7 url: "https://pub.dev" source: hosted - version: "2.27.0" + version: "2.28.0" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: "7ad88b8982e753eadcdbc0ea7c7d30500598af733601428b5c9d264baf5106d6" + sha256: dce092d556aa11ee808537ab32e0657f235dc20ccfc300320e916461ae88abcc url: "https://pub.dev" source: hosted - version: "2.27.0" + version: "2.28.1-dev.0" fake_async: dependency: "direct dev" description: @@ -358,10 +358,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: c6e8a6bf883d8ddd0dec39be90872daca65beaa6f4cff0051ed3b16c56b82e9f + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" url: "https://pub.dev" source: hosted - version: "3.15.1" + version: "3.15.2" firebase_core_platform_interface: dependency: transitive description: @@ -382,26 +382,26 @@ packages: dependency: "direct main" description: name: firebase_messaging - sha256: "0f3363f97672eb9f65609fa00ed2f62cc8ec93e7e2d4def99726f9165d3d8a73" + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" url: "https://pub.dev" source: hosted - version: "15.2.9" + version: "15.2.10" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "7a05ef119a14c5f6a9440d1e0223bcba20c8daf555450e119c4c477bf2c3baa9" + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" url: "https://pub.dev" source: hosted - version: "4.6.9" + version: "4.6.10" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: a4547f76da2a905190f899eb4d0150e1d0fd52206fce469d9f05ae15bb68b2c5 + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" url: "https://pub.dev" source: hosted - version: "3.10.9" + version: "3.10.10" fixnum: dependency: transitive description: @@ -541,10 +541,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "317a5d961cec5b34e777b9252393f2afbd23084aa6e60fcf601dcf6341b9ebeb" + sha256: "6fae381e6af2bbe0365a5e4ce1db3959462fa0c4d234facf070746024bb80c8d" url: "https://pub.dev" source: hosted - version: "0.8.12+23" + version: "0.8.12+24" image_picker_for_web: dependency: transitive description: @@ -642,10 +642,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + sha256: ce2cf974ccdee13be2a510832d7fba0b94b364e0b0395dee42abaa51b855be27 url: "https://pub.dev" source: hosted - version: "6.9.5" + version: "6.10.0" leak_tracker: dependency: transitive description: @@ -714,10 +714,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -826,10 +826,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "9436fe11f82d7cc1642a8671e5aa4149ffa9ae9116e6cf6dd665fc0653e3825c" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.0" pigeon: dependency: "direct dev" description: @@ -959,18 +959,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: fc787b1f89ceac9580c3616f899c9a447413cbdac1df071302127764c023a134 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + sha256: "4f81479fe5194a622cdd1713fe1ecb683a6e6c85cd8cec8e2e35ee5ab3fdf2a1" url: "https://pub.dev" source: hosted - version: "1.3.5" + version: "1.3.6" source_map_stack_trace: dependency: transitive description: @@ -1007,26 +1007,26 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38 + sha256: "608b56d594e4c8498c972c8f1507209f9fd74939971b948ddbbfbfd1c9cb3c15" url: "https://pub.dev" source: hosted - version: "2.7.6" + version: "2.7.7" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: e07232b998755fe795655c56d1f5426e0190c9c435e1752d39e7b1cd33699c71 + sha256: "60464aa06f3f6f6fba9abd7564e315526c1fee6d6a77d6ee52a1f7f48a9107f6" url: "https://pub.dev" source: hosted - version: "0.5.34" + version: "0.5.37" sqlparser: dependency: transitive description: name: sqlparser - sha256: "27dd0a9f0c02e22ac0eb42a23df9ea079ce69b52bb4a3b478d64e0ef34a263ee" + sha256: "7c859c803cf7e9a84d6db918bac824545045692bbe94a6386bd3a45132235d09" url: "https://pub.dev" source: hosted - version: "0.41.0" + version: "0.41.1" stack_trace: dependency: "direct dev" description: @@ -1119,10 +1119,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: "direct main" description: @@ -1215,10 +1215,10 @@ packages: dependency: transitive description: name: video_player_avfoundation - sha256: "0d47db6cbf72db61d86369219efd35c7f9d93515e1319da941ece81b1f21c49c" + sha256: "9fedd55023249f3a02738c195c906b4e530956191febf0838e37d0dac912f953" url: "https://pub.dev" source: hosted - version: "2.7.2" + version: "2.8.0" video_player_platform_interface: dependency: "direct dev" description: @@ -1231,10 +1231,10 @@ packages: dependency: transitive description: name: video_player_web - sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.4.0" vm_service: dependency: transitive description: @@ -1335,10 +1335,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "3202a47961c1a0af6097c9f8c1b492d705248ba309e6f7a72410422c05046851" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.0" yaml: dependency: transitive description: @@ -1355,5 +1355,5 @@ packages: source: path version: "0.0.1" sdks: - dart: ">=3.9.0-293.0.dev <4.0.0" - flutter: ">=3.33.0-1.0.pre.832" + dart: ">=3.10.0-21.0.dev <4.0.0" + flutter: ">=3.33.0-1.0.pre.1086" diff --git a/pubspec.yaml b/pubspec.yaml index a87dc4a4bd..89fbdd4cb9 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-293.0.dev <4.0.0' - flutter: '>=3.33.0-1.0.pre.832' # d35bde5363d5e25b71d69a81e8c93b0ee3272609 + sdk: '>=3.10.0-21.0.dev <4.0.0' + flutter: '>=3.33.0-1.0.pre.1086' # fa80cbcbdbdddda32bd4e209fd43f2beee69a10b # To update dependencies, see instructions in README.md. dependencies: From 3addf690bf1f4cb9f58484219cd6a28d2dd82c9d Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 24 Jul 2025 18:19:34 -0700 Subject: [PATCH 406/423] deps: Revert unintentionally-broad upgrade This reverts commit 55d055f03f0a01f10379aa4e82e6a757b805d75f (#1738). That change was described as upgrading Flutter and its supporting libraries, but it looks like it accidentally included other upgrades, something like `flutter pub upgrade`. It also didn't include the CocoaPods updates for those upgrades; see #1746. So revert that change, to get back to a well-understood state. Then we can retry the upgrade. --- pubspec.lock | 116 +++++++++++++++++++++++++-------------------------- pubspec.yaml | 4 +- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 5537bd5c74..e693e77f23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,18 +13,18 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + sha256: a5788040810bd84400bc209913fbc40f388cded7cdf95ee2f5d2bff7e38d5241 url: "https://pub.dev" source: hosted - version: "1.3.59" + version: "1.3.58" analyzer: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: "01949bf52ad33f0e0f74f881fbaac4f348c556531951d92c8d16f1262aa19ff8" url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "7.5.4" app_settings: dependency: "direct main" description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: build - sha256: "7d95cbbb1526ab5ae977df9b4cc660963b9b27f6d1075c0b34653868911385e4" + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.5.4" build_config: dependency: transitive description: @@ -85,26 +85,26 @@ packages: dependency: transitive description: name: build_resolvers - sha256: "38c9c339333a09b090a638849a4c56e70a404c6bdd3b511493addfbc113b60c2" + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: b971d4a1c789eba7be3e6fe6ce5e5b50fd3719e3cb485b3fad6d04358304351d + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.5.4" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: c04e612ca801cd0928ccdb891c263a2b1391cb27940a5ea5afcf9ba894de5d62 + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" url: "https://pub.dev" source: hosted - version: "9.2.0" + version: "9.1.2" built_collection: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: "0b1b12a0a549605e5f04476031cd0bc91ead1d7c8e830773a18ee54179b3cb62" + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" url: "https://pub.dev" source: hosted - version: "8.11.0" + version: "8.10.1" characters: dependency: transitive description: @@ -214,10 +214,10 @@ packages: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.14.1" cross_file: dependency: transitive description: @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.0" dbus: dependency: transitive description: @@ -278,18 +278,18 @@ packages: dependency: "direct main" description: name: drift - sha256: dce2723fb0dd03563af21f305f8f96514c27f870efba934b4fe84d4fedb4eff7 + sha256: e60c715f045dd33624fc533efb0075e057debec9f39e83843e518f488a0e21fb url: "https://pub.dev" source: hosted - version: "2.28.0" + version: "2.27.0" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: dce092d556aa11ee808537ab32e0657f235dc20ccfc300320e916461ae88abcc + sha256: "7ad88b8982e753eadcdbc0ea7c7d30500598af733601428b5c9d264baf5106d6" url: "https://pub.dev" source: hosted - version: "2.28.1-dev.0" + version: "2.27.0" fake_async: dependency: "direct dev" description: @@ -358,10 +358,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + sha256: c6e8a6bf883d8ddd0dec39be90872daca65beaa6f4cff0051ed3b16c56b82e9f url: "https://pub.dev" source: hosted - version: "3.15.2" + version: "3.15.1" firebase_core_platform_interface: dependency: transitive description: @@ -382,26 +382,26 @@ packages: dependency: "direct main" description: name: firebase_messaging - sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + sha256: "0f3363f97672eb9f65609fa00ed2f62cc8ec93e7e2d4def99726f9165d3d8a73" url: "https://pub.dev" source: hosted - version: "15.2.10" + version: "15.2.9" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + sha256: "7a05ef119a14c5f6a9440d1e0223bcba20c8daf555450e119c4c477bf2c3baa9" url: "https://pub.dev" source: hosted - version: "4.6.10" + version: "4.6.9" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + sha256: a4547f76da2a905190f899eb4d0150e1d0fd52206fce469d9f05ae15bb68b2c5 url: "https://pub.dev" source: hosted - version: "3.10.10" + version: "3.10.9" fixnum: dependency: transitive description: @@ -541,10 +541,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "6fae381e6af2bbe0365a5e4ce1db3959462fa0c4d234facf070746024bb80c8d" + sha256: "317a5d961cec5b34e777b9252393f2afbd23084aa6e60fcf601dcf6341b9ebeb" url: "https://pub.dev" source: hosted - version: "0.8.12+24" + 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: ce2cf974ccdee13be2a510832d7fba0b94b364e0b0395dee42abaa51b855be27 + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.10.0" + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -714,10 +714,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: "direct main" description: @@ -826,10 +826,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "9436fe11f82d7cc1642a8671e5aa4149ffa9ae9116e6cf6dd665fc0653e3825c" + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "6.1.0" pigeon: dependency: "direct dev" description: @@ -959,18 +959,18 @@ packages: dependency: transitive description: name: source_gen - sha256: fc787b1f89ceac9580c3616f899c9a447413cbdac1df071302127764c023a134 + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "4f81479fe5194a622cdd1713fe1ecb683a6e6c85cd8cec8e2e35ee5ab3fdf2a1" + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" url: "https://pub.dev" source: hosted - version: "1.3.6" + version: "1.3.5" source_map_stack_trace: dependency: transitive description: @@ -1007,26 +1007,26 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: "608b56d594e4c8498c972c8f1507209f9fd74939971b948ddbbfbfd1c9cb3c15" + sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38 url: "https://pub.dev" source: hosted - version: "2.7.7" + version: "2.7.6" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "60464aa06f3f6f6fba9abd7564e315526c1fee6d6a77d6ee52a1f7f48a9107f6" + sha256: e07232b998755fe795655c56d1f5426e0190c9c435e1752d39e7b1cd33699c71 url: "https://pub.dev" source: hosted - version: "0.5.37" + version: "0.5.34" sqlparser: dependency: transitive description: name: sqlparser - sha256: "7c859c803cf7e9a84d6db918bac824545045692bbe94a6386bd3a45132235d09" + sha256: "27dd0a9f0c02e22ac0eb42a23df9ea079ce69b52bb4a3b478d64e0ef34a263ee" url: "https://pub.dev" source: hosted - version: "0.41.1" + version: "0.41.0" stack_trace: dependency: "direct dev" description: @@ -1119,10 +1119,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.1" url_launcher_android: dependency: "direct main" description: @@ -1215,10 +1215,10 @@ packages: dependency: transitive description: name: video_player_avfoundation - sha256: "9fedd55023249f3a02738c195c906b4e530956191febf0838e37d0dac912f953" + sha256: "0d47db6cbf72db61d86369219efd35c7f9d93515e1319da941ece81b1f21c49c" url: "https://pub.dev" source: hosted - version: "2.8.0" + version: "2.7.2" video_player_platform_interface: dependency: "direct dev" description: @@ -1231,10 +1231,10 @@ packages: dependency: transitive description: name: video_player_web - sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.3.5" vm_service: dependency: transitive description: @@ -1335,10 +1335,10 @@ packages: dependency: transitive description: name: xml - sha256: "3202a47961c1a0af6097c9f8c1b492d705248ba309e6f7a72410422c05046851" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.6.0" + version: "6.5.0" yaml: dependency: transitive description: @@ -1355,5 +1355,5 @@ packages: source: path version: "0.0.1" sdks: - dart: ">=3.10.0-21.0.dev <4.0.0" - flutter: ">=3.33.0-1.0.pre.1086" + dart: ">=3.9.0-293.0.dev <4.0.0" + flutter: ">=3.33.0-1.0.pre.832" diff --git a/pubspec.yaml b/pubspec.yaml index 89fbdd4cb9..a87dc4a4bd 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.10.0-21.0.dev <4.0.0' - flutter: '>=3.33.0-1.0.pre.1086' # fa80cbcbdbdddda32bd4e209fd43f2beee69a10b + sdk: '>=3.9.0-293.0.dev <4.0.0' + flutter: '>=3.33.0-1.0.pre.832' # d35bde5363d5e25b71d69a81e8c93b0ee3272609 # To update dependencies, see instructions in README.md. dependencies: From f9ec516e3d3779153c41966d7041054fbf7ab2cc Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sat, 12 Jul 2025 23:12:18 -0400 Subject: [PATCH 407/423] lightbox [nfc]: Go through MessageTimestampStyle for timestamp --- lib/widgets/lightbox.dart | 9 +++------ lib/widgets/message_list.dart | 5 +++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index bf11522036..3abff9674a 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; import 'package:video_player/video_player.dart'; import '../api/core.dart'; @@ -12,6 +11,7 @@ import '../model/binding.dart'; import 'actions.dart'; import 'content.dart'; import 'dialog.dart'; +import 'message_list.dart'; import 'page.dart'; import 'store.dart'; import 'user.dart'; @@ -176,11 +176,8 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { PreferredSizeWidget? appBar; if (_headerFooterVisible) { - // TODO(#45): Format with e.g. "Yesterday at 4:47 PM" - final timestampText = DateFormat - .yMMMd(/* TODO(#278): Pass selected language here, I think? */) - .add_Hms() - .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000)); + final timestampText = MessageTimestampStyle.lightbox + .format(widget.message.timestamp); // We use plain [AppBar] instead of [ZulipAppBar], even though this page // has a [PerAccountStore], because: diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index eccdf8c6a6..73bb9da7a8 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1987,6 +1987,9 @@ enum MessageTimestampStyle { none, timeOnly, + // TODO(#45): E.g. "Yesterday at 4:47 PM"; see details in #45 + lightbox, + /// The longest format, with full date and time as numbers, not "Today"/etc. /// /// For UI contexts focused just on the one message, @@ -2002,6 +2005,7 @@ enum MessageTimestampStyle { ; static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US'); + static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); static final _fullFormat = DateFormat.yMMMd().add_jm(); /// Format a [Message.timestamp] for this mode. @@ -2013,6 +2017,7 @@ enum MessageTimestampStyle { switch (this) { case none: return null; case timeOnly: return _timeOnlyFormat.format(asDateTime); + case lightbox: return _lightboxFormat.format(asDateTime); case full: return _fullFormat.format(asDateTime); } } From 55b95d2e766df686b58e6f65fe65ea9a6bb5f6b0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 14 Jul 2025 12:33:46 -0400 Subject: [PATCH 408/423] msglist [nfc]: Add two params to MessageTimestampStyle.format, to use soon --- lib/widgets/lightbox.dart | 5 +++-- lib/widgets/message_list.dart | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 3abff9674a..98e9aae4e5 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -167,6 +167,7 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final themeData = Theme.of(context); @@ -176,8 +177,8 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { PreferredSizeWidget? appBar; if (_headerFooterVisible) { - final timestampText = MessageTimestampStyle.lightbox - .format(widget.message.timestamp); + final timestampText = MessageTimestampStyle.lightbox.format( + widget.message.timestamp, now: DateTime.now(), zulipLocalizations: zulipLocalizations); // We use plain [AppBar] instead of [ZulipAppBar], even though this page // has a [PerAccountStore], because: diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 73bb9da7a8..dddfa3399a 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1915,12 +1915,14 @@ class SenderRow extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); final sender = store.getUser(message.senderId); - final timestamp = timestampStyle.format(message.timestamp); + final timestamp = timestampStyle.format( + message.timestamp, now: DateTime.now(), zulipLocalizations: zulipLocalizations); final showAsMuted = _showAsMuted(context, store); @@ -2010,7 +2012,11 @@ enum MessageTimestampStyle { /// Format a [Message.timestamp] for this mode. // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi) - String? format(int messageTimestamp) { + String? format( + int messageTimestamp, { + required DateTime now, + required ZulipLocalizations zulipLocalizations, + }) { final asDateTime = DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp); From 7704fd8131bfa18caeb17a72233b3c0b4a3ecebd Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 15:46:28 -0700 Subject: [PATCH 409/423] msglist [nfc]: Use ZulipBinding.instance.utcNow().toLocal() in DateText Thanks Komyyy for this idea, which I took from PR #1363. Co-authored-by: Komyyy --- lib/widgets/message_list.dart | 3 ++- test/widgets/message_list_test.dart | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index dddfa3399a..1bf60891bf 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; import '../model/database.dart'; import '../model/message.dart'; import '../model/message_list.dart'; @@ -1854,7 +1855,7 @@ class DateText extends StatelessWidget { formatHeaderDate( zulipLocalizations, DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - now: DateTime.now())); + now: ZulipBinding.instance.utcNow().toLocal())); } } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index a7c4b14dbf..0c0518667d 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -1654,8 +1655,16 @@ void main() { ]; for (final (dateTime, expected) in testCases) { test('$dateTime returns $expected', () { - check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime), now: now)) - .equals(expected); + addTearDown(testBinding.reset); + + withClock(Clock.fixed(now), () { + check(formatHeaderDate( + zulipLocalizations, + DateTime.parse(dateTime), + now: testBinding.utcNow().toLocal(), + )) + .equals(expected); + }); }); } }); From 3703baa2d6abe19c8ade814797185f05e2b13411 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 14 Jul 2025 12:30:56 -0400 Subject: [PATCH 410/423] msglist [nfc]: Finish centralizing on MessageTimestampStyle --- lib/widgets/message_list.dart | 84 +++++++++++++++-------------- test/widgets/message_list_test.dart | 60 +++++++++++---------- 2 files changed, 75 insertions(+), 69 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 1bf60891bf..0bd5a8eb65 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1843,6 +1843,10 @@ class DateText extends StatelessWidget { Widget build(BuildContext context) { final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + final formattedTimestamp = MessageTimestampStyle.dateOnlyRelative.format( + timestamp, + now: ZulipBinding.instance.utcNow().toLocal(), + zulipLocalizations: zulipLocalizations)!; return Text( style: TextStyle( color: messageListTheme.labelTime, @@ -1852,46 +1856,7 @@ class DateText extends StatelessWidget { // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], ), - formatHeaderDate( - zulipLocalizations, - DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - now: ZulipBinding.instance.utcNow().toLocal())); - } -} - -@visibleForTesting -String formatHeaderDate( - ZulipLocalizations zulipLocalizations, - DateTime dateTime, { - required DateTime now, -}) { - assert(!dateTime.isUtc && !now.isUtc, - '`dateTime` and `now` need to be in local time.'); - - if (dateTime.year == now.year && - dateTime.month == now.month && - dateTime.day == now.day) { - return zulipLocalizations.today; - } - - final yesterday = now - .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0) - .add(const Duration(days: -1)); - if (dateTime.year == yesterday.year && - dateTime.month == yesterday.month && - dateTime.day == yesterday.day) { - return zulipLocalizations.yesterday; - } - - // If it is Dec 1 and you see a label that says `Dec 2` - // it could be misinterpreted as Dec 2 of the previous - // year. For times in the future, those still on the - // current day will show as today (handled above) and - // any dates beyond that show up with the year. - if (dateTime.year == now.year && dateTime.isBefore(now)) { - return DateFormat.MMMd().format(dateTime); - } else { - return DateFormat.yMMMd().format(dateTime); + formattedTimestamp); } } @@ -1985,9 +1950,9 @@ class SenderRow extends StatelessWidget { } } -// TODO centralize on this for wherever we show message timestamps enum MessageTimestampStyle { none, + dateOnlyRelative, timeOnly, // TODO(#45): E.g. "Yesterday at 4:47 PM"; see details in #45 @@ -2007,6 +1972,40 @@ enum MessageTimestampStyle { full, ; + static String _formatDateOnlyRelative( + DateTime dateTime, { + required DateTime now, + required ZulipLocalizations zulipLocalizations, + }) { + assert(!dateTime.isUtc && !now.isUtc, + '`dateTime` and `now` need to be in local time.'); + + if (dateTime.year == now.year && + dateTime.month == now.month && + dateTime.day == now.day) { + return zulipLocalizations.today; + } + + final yesterday = now + .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0) + .add(const Duration(days: -1)); + if (dateTime.year == yesterday.year && + dateTime.month == yesterday.month && + dateTime.day == yesterday.day) { + return zulipLocalizations.yesterday; + } + + // If it is Dec 1 and you see a label that says `Dec 2` + // it could be misinterpreted as Dec 2 of the previous + // year. For times in the future, those still on the + // current day will show as today (handled above) and + // any dates beyond that show up with the year. + if (dateTime.year == now.year && dateTime.isBefore(now)) { + return DateFormat.MMMd().format(dateTime); + } else { + return DateFormat.yMMMd().format(dateTime); + } + } static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US'); static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); static final _fullFormat = DateFormat.yMMMd().add_jm(); @@ -2023,6 +2022,9 @@ enum MessageTimestampStyle { switch (this) { case none: return null; + case dateOnlyRelative: + return _formatDateOnlyRelative(asDateTime, + now: now, zulipLocalizations: zulipLocalizations); case timeOnly: return _timeOnlyFormat.format(asDateTime); case lightbox: return _lightboxFormat.format(asDateTime); case full: return _fullFormat.format(asDateTime); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 0c0518667d..6468eb6266 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1638,35 +1638,39 @@ void main() { }); }); - group('formatHeaderDate', () { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final now = DateTime.parse("2023-01-10 12:00"); - final testCases = [ - ("2023-01-10 12:00", zulipLocalizations.today), - ("2023-01-10 00:00", zulipLocalizations.today), - ("2023-01-10 23:59", zulipLocalizations.today), - ("2023-01-09 23:59", zulipLocalizations.yesterday), - ("2023-01-09 00:00", zulipLocalizations.yesterday), - ("2023-01-08 00:00", "Jan 8"), - ("2022-12-31 00:00", "Dec 31, 2022"), - // Future times - ("2023-01-10 19:00", zulipLocalizations.today), - ("2023-01-11 00:00", "Jan 11, 2023"), - ]; - for (final (dateTime, expected) in testCases) { - test('$dateTime returns $expected', () { - addTearDown(testBinding.reset); - - withClock(Clock.fixed(now), () { - check(formatHeaderDate( - zulipLocalizations, - DateTime.parse(dateTime), - now: testBinding.utcNow().toLocal(), - )) - .equals(expected); + group('MessageTimestampStyle', () { + group('dateOnlyRelative', () { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + final now = DateTime.parse("2023-01-10 12:00"); + final testCases = [ + ("2023-01-10 12:00", zulipLocalizations.today), + ("2023-01-10 00:00", zulipLocalizations.today), + ("2023-01-10 23:59", zulipLocalizations.today), + ("2023-01-09 23:59", zulipLocalizations.yesterday), + ("2023-01-09 00:00", zulipLocalizations.yesterday), + ("2023-01-08 00:00", "Jan 8"), + ("2022-12-31 00:00", "Dec 31, 2022"), + // Future times + ("2023-01-10 19:00", zulipLocalizations.today), + ("2023-01-11 00:00", "Jan 11, 2023"), + ]; + for (final (dateTime, expected) in testCases) { + test('$dateTime returns $expected', () { + addTearDown(testBinding.reset); + + withClock(Clock.fixed(now), () { + final timestamp = DateTime.parse(dateTime).millisecondsSinceEpoch ~/ 1000; + final result = MessageTimestampStyle.dateOnlyRelative.format( + timestamp, + now: testBinding.utcNow().toLocal(), + zulipLocalizations: zulipLocalizations); + check(result).equals(expected); + }); }); - }); - } + } + }); + + // TODO others }); group('MessageWithPossibleSender', () { From 0a6b2f75fb98fe5e2198bcc67fd0059f348800b7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 23 Jul 2025 14:52:09 -0700 Subject: [PATCH 411/423] msglist test: Test all `MessageTimestampStyle`s, not just dateOnlyRelative --- test/widgets/message_list_test.dart | 64 +++++++++++++++++++---------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 6468eb6266..569fbc1978 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1639,28 +1639,21 @@ void main() { }); group('MessageTimestampStyle', () { - group('dateOnlyRelative', () { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final now = DateTime.parse("2023-01-10 12:00"); - final testCases = [ - ("2023-01-10 12:00", zulipLocalizations.today), - ("2023-01-10 00:00", zulipLocalizations.today), - ("2023-01-10 23:59", zulipLocalizations.today), - ("2023-01-09 23:59", zulipLocalizations.yesterday), - ("2023-01-09 00:00", zulipLocalizations.yesterday), - ("2023-01-08 00:00", "Jan 8"), - ("2022-12-31 00:00", "Dec 31, 2022"), - // Future times - ("2023-01-10 19:00", zulipLocalizations.today), - ("2023-01-11 00:00", "Jan 11, 2023"), - ]; - for (final (dateTime, expected) in testCases) { - test('$dateTime returns $expected', () { + void doTests( + MessageTimestampStyle style, + List<(String timestampStr, String? expected)> cases, { + DateTime? now, + }) { + now ??= DateTime.parse("2023-01-10 12:00"); + for (final (timestampStr, expected) in cases) { + test('${style.name}: $timestampStr returns $expected', () { addTearDown(testBinding.reset); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - withClock(Clock.fixed(now), () { - final timestamp = DateTime.parse(dateTime).millisecondsSinceEpoch ~/ 1000; - final result = MessageTimestampStyle.dateOnlyRelative.format( + withClock(Clock.fixed(now!), () { + final timestamp = DateTime.parse(timestampStr) + .millisecondsSinceEpoch ~/ 1000; + final result = style.format( timestamp, now: testBinding.utcNow().toLocal(), zulipLocalizations: zulipLocalizations); @@ -1668,9 +1661,36 @@ void main() { }); }); } - }); + } - // TODO others + for (final style in MessageTimestampStyle.values) { + switch (style) { + case MessageTimestampStyle.none: + doTests(style, [('2023-01-10 12:00', null)]); + case MessageTimestampStyle.dateOnlyRelative: + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + doTests(style, + now: DateTime.parse("2023-01-10 12:00"), + [ + ("2023-01-10 12:00", zulipLocalizations.today), + ("2023-01-10 00:00", zulipLocalizations.today), + ("2023-01-10 23:59", zulipLocalizations.today), + ("2023-01-09 23:59", zulipLocalizations.yesterday), + ("2023-01-09 00:00", zulipLocalizations.yesterday), + ("2023-01-08 00:00", "Jan 8"), + ("2022-12-31 00:00", "Dec 31, 2022"), + // Future times + ("2023-01-10 19:00", zulipLocalizations.today), + ("2023-01-11 00:00", "Jan 11, 2023"), + ]); + case MessageTimestampStyle.timeOnly: + doTests(style, [('2023-01-10 12:00', '12:00 PM')]); + case MessageTimestampStyle.lightbox: + doTests(style, [('2023-01-10 12:00', 'Jan 10, 2023 12:00:00')]); + case MessageTimestampStyle.full: + doTests(style, [('2023-01-10 12:00', 'Jan 10, 2023 12:00 PM')]); + } + } }); group('MessageWithPossibleSender', () { From 4a77fbd14b5b4bce7d574e76172f7fc071785097 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 14 Jul 2025 17:17:29 -0400 Subject: [PATCH 412/423] api: Start accepting null for user_settings.twenty_four_hour_time Servers can't yet start sending null without breaking clients. Releasing this will start lowering the number of client installs that would break, and eventually there will be few enough that the breakage is acceptable; see discussion (same link as in comment): https://chat.zulip.org/#narrow/channel/378-api-design/topic/.60user_settings.2Etwenty_four_hour_time.60/near/2220696 --- lib/api/model/events.dart | 1 + lib/api/model/initial_snapshot.dart | 7 ++++++- lib/api/model/initial_snapshot.g.dart | 8 ++++++-- lib/api/model/model.dart | 29 +++++++++++++++++++++++++++ lib/api/route/settings.dart | 20 ++++++++++++------ lib/model/store.dart | 2 +- test/api/route/settings_test.dart | 16 +++++++++++++-- test/example_data.dart | 2 +- test/model/store_test.dart | 12 +++++++---- 9 files changed, 80 insertions(+), 17 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index bb3d7245e0..6070616387 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -175,6 +175,7 @@ class UserSettingsUpdateEvent extends Event { final value = json['value']; switch (UserSettingName.fromRawString(json['property'] as String)) { case UserSettingName.twentyFourHourTime: + return TwentyFourHourTimeMode.fromApiValue(value as bool?); case UserSettingName.displayEmojiReactionUsers: return value as bool; case UserSettingName.emojiset: diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 45b97745d6..d9734582c6 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -259,7 +259,12 @@ class RecentDmConversation { /// in . @JsonSerializable(fieldRename: FieldRename.snake, createFieldMap: true) class UserSettings { - bool twentyFourHourTime; + @JsonKey( + fromJson: TwentyFourHourTimeMode.fromApiValue, + toJson: TwentyFourHourTimeMode.staticToJson, + ) + TwentyFourHourTimeMode twentyFourHourTime; + bool? displayEmojiReactionUsers; // TODO(server-6) Emojiset emojiset; bool presenceEnabled; diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index c8b50a7b78..32af6a1867 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -230,7 +230,9 @@ Map _$RecentDmConversationToJson( }; UserSettings _$UserSettingsFromJson(Map json) => UserSettings( - twentyFourHourTime: json['twenty_four_hour_time'] as bool, + twentyFourHourTime: TwentyFourHourTimeMode.fromApiValue( + json['twenty_four_hour_time'] as bool?, + ), displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool?, emojiset: $enumDecode(_$EmojisetEnumMap, json['emojiset']), presenceEnabled: json['presence_enabled'] as bool, @@ -245,7 +247,9 @@ const _$UserSettingsFieldMap = { Map _$UserSettingsToJson(UserSettings instance) => { - 'twenty_four_hour_time': instance.twentyFourHourTime, + 'twenty_four_hour_time': TwentyFourHourTimeMode.staticToJson( + instance.twentyFourHourTime, + ), 'display_emoji_reaction_users': instance.displayEmojiReactionUsers, 'emojiset': instance.emojiset, 'presence_enabled': instance.presenceEnabled, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 4302a82c11..1009418d08 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -298,6 +298,35 @@ enum UserSettingName { String toJson() => _$UserSettingNameEnumMap[this]!; } +/// A value from [UserSettings.twentyFourHourTime]. +enum TwentyFourHourTimeMode { + twelveHour(apiValue: false), + twentyFourHour(apiValue: true), + + /// The locale's default format (12-hour for en_US, 24-hour for fr_FR, etc.). + // TODO(#1727) actually follow this + // Not sent by current servers, but planned when most client installs accept it: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/.60user_settings.2Etwenty_four_hour_time.60/near/2220696 + // TODO(server-future) Write down what server N starts sending null; + // adjust the comment; leave a TODO(server-N) to delete the comment + localeDefault(apiValue: null), + ; + + const TwentyFourHourTimeMode({required this.apiValue}); + + final bool? apiValue; + + static bool? staticToJson(TwentyFourHourTimeMode instance) => instance.apiValue; + + bool? toJson() => TwentyFourHourTimeMode.staticToJson(this); + + static TwentyFourHourTimeMode fromApiValue(bool? value) => switch (value) { + false => twelveHour, + true => twentyFourHour, + null => localeDefault, + }; +} + /// As in [UserSettings.emojiset]. @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true) enum Emojiset { diff --git a/lib/api/route/settings.dart b/lib/api/route/settings.dart index 929a199d0b..4e98140d76 100644 --- a/lib/api/route/settings.dart +++ b/lib/api/route/settings.dart @@ -9,12 +9,20 @@ Future updateSettings(ApiConnection connection, { for (final entry in newSettings.entries) { final name = entry.key; final valueRaw = entry.value; - final value = switch (name) { - UserSettingName.twentyFourHourTime => valueRaw as bool, - UserSettingName.displayEmojiReactionUsers => valueRaw as bool, - UserSettingName.emojiset => RawParameter((valueRaw as Emojiset).toJson()), - UserSettingName.presenceEnabled => valueRaw as bool, - }; + final Object? value; + switch (name) { + case UserSettingName.twentyFourHourTime: + final mode = (valueRaw as TwentyFourHourTimeMode); + // TODO(server-future) allow localeDefault for servers that support it + assert(mode != TwentyFourHourTimeMode.localeDefault); + value = mode.toJson(); + case UserSettingName.displayEmojiReactionUsers: + value = valueRaw as bool; + case UserSettingName.emojiset: + value = RawParameter((valueRaw as Emojiset).toJson()); + case UserSettingName.presenceEnabled: + value = valueRaw as bool; + } params[name.toJson()] = value; } diff --git a/lib/model/store.dart b/lib/model/store.dart index 03ff13022a..a24550c406 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -707,7 +707,7 @@ class PerAccountStore extends PerAccountStoreBase with } switch (event.property!) { case UserSettingName.twentyFourHourTime: - userSettings.twentyFourHourTime = event.value as bool; + userSettings.twentyFourHourTime = event.value as TwentyFourHourTimeMode; case UserSettingName.displayEmojiReactionUsers: userSettings.displayEmojiReactionUsers = event.value as bool; case UserSettingName.emojiset: diff --git a/test/api/route/settings_test.dart b/test/api/route/settings_test.dart index d2808ece02..8b31caa646 100644 --- a/test/api/route/settings_test.dart +++ b/test/api/route/settings_test.dart @@ -17,8 +17,8 @@ void main() { for (final name in UserSettingName.values) { switch (name) { case UserSettingName.twentyFourHourTime: - newSettings[name] = true; - expectedBodyFields['twenty_four_hour_time'] = 'true'; + newSettings[name] = TwentyFourHourTimeMode.twelveHour; + expectedBodyFields['twenty_four_hour_time'] = 'false'; case UserSettingName.displayEmojiReactionUsers: newSettings[name] = false; expectedBodyFields['display_emoji_reaction_users'] = 'false'; @@ -38,4 +38,16 @@ void main() { ..bodyFields.deepEquals(expectedBodyFields); }); }); + + test('TwentyFourHourTime.localeDefault', () async { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + + // TODO(server-future) instead, check for twenty_four_hour_time: null + // (could be an error-prone part of the JSONification) + check(() => updateSettings(connection, + newSettings: {UserSettingName.twentyFourHourTime: TwentyFourHourTimeMode.localeDefault}) + ).throws(); + }); + }); } diff --git a/test/example_data.dart b/test/example_data.dart index c33c5fdfdc..b44108eb21 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1262,7 +1262,7 @@ InitialSnapshot initialSnapshot({ streams: streams ?? [], // TODO add streams to default userStatuses: userStatuses ?? {}, userSettings: userSettings ?? UserSettings( - twentyFourHourTime: false, + twentyFourHourTime: TwentyFourHourTimeMode.twelveHour, displayEmojiReactionUsers: true, emojiset: Emojiset.google, presenceEnabled: true, diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 240f2cbffd..c90b45a25f 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -698,14 +698,16 @@ void main() { await preparePoll(); // Pick some arbitrary event and check it gets processed on the store. - check(store.userSettings.twentyFourHourTime).isFalse(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twelveHour); connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: UserSettingName.twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store.userSettings.twentyFourHourTime).isTrue(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twentyFourHour); })); void checkReload(FutureOr Function() prepareError, { @@ -735,14 +737,16 @@ void main() { // The new UpdateMachine updates the new store. updateMachine.debugPauseLoop(); updateMachine.poll(); - check(store.userSettings.twentyFourHourTime).isFalse(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twelveHour); connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: UserSettingName.twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); - check(store.userSettings.twentyFourHourTime).isTrue(); + check(store.userSettings.twentyFourHourTime) + .equals(TwentyFourHourTimeMode.twentyFourHour); }); } From ddc9adeca55ed02021e194afed21fc391d2de671 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 15 Jul 2025 17:17:39 -0400 Subject: [PATCH 413/423] model [nfc]: Pass TwentyFourHourTimeMode to MessageTimestampStyle.format --- lib/widgets/lightbox.dart | 7 +++++-- lib/widgets/message_list.dart | 10 ++++++++-- test/widgets/message_list_test.dart | 1 + 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 98e9aae4e5..0a1fe78feb 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -177,8 +177,11 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { PreferredSizeWidget? appBar; if (_headerFooterVisible) { - final timestampText = MessageTimestampStyle.lightbox.format( - widget.message.timestamp, now: DateTime.now(), zulipLocalizations: zulipLocalizations); + final timestampText = MessageTimestampStyle.lightbox + .format(widget.message.timestamp, + now: DateTime.now(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations); // We use plain [AppBar] instead of [ZulipAppBar], even though this page // has a [PerAccountStore], because: diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 0bd5a8eb65..9af5889deb 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1841,11 +1841,13 @@ class DateText extends StatelessWidget { @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final formattedTimestamp = MessageTimestampStyle.dateOnlyRelative.format( timestamp, now: ZulipBinding.instance.utcNow().toLocal(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, zulipLocalizations: zulipLocalizations)!; return Text( style: TextStyle( @@ -1887,8 +1889,11 @@ class SenderRow extends StatelessWidget { final designVariables = DesignVariables.of(context); final sender = store.getUser(message.senderId); - final timestamp = timestampStyle.format( - message.timestamp, now: DateTime.now(), zulipLocalizations: zulipLocalizations); + final timestamp = timestampStyle + .format(message.timestamp, + now: DateTime.now(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations); final showAsMuted = _showAsMuted(context, store); @@ -2016,6 +2021,7 @@ enum MessageTimestampStyle { int messageTimestamp, { required DateTime now, required ZulipLocalizations zulipLocalizations, + required TwentyFourHourTimeMode twentyFourHourTimeMode, }) { final asDateTime = DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 569fbc1978..43aa4ac79e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1656,6 +1656,7 @@ void main() { final result = style.format( timestamp, now: testBinding.utcNow().toLocal(), + twentyFourHourTimeMode: TwentyFourHourTimeMode.localeDefault, zulipLocalizations: zulipLocalizations); check(result).equals(expected); }); From 1d1f426d7901a52346293a40ff71bce4feb60f9b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 15 Jul 2025 18:52:36 -0400 Subject: [PATCH 414/423] msglist [nfc]: Remove a hard-coded 'en_US' which is the default See the doc: https://pub.dev/documentation/intl/latest/intl/DateFormat-class.html > Formatting dates in the default 'en_US' format does not require > any initialization. (And we haven't been doing the described initialization for 'en_US' or any other locale; it's asynchronous, and we have a better plan for international formatting described in #45, using ffi.) --- 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 9af5889deb..11b87fb228 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2011,7 +2011,7 @@ enum MessageTimestampStyle { return DateFormat.yMMMd().format(dateTime); } } - static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US'); + static final _timeOnlyFormat = DateFormat('h:mm aa'); static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); static final _fullFormat = DateFormat.yMMMd().add_jm(); From b7146a7ae28ce13ca7118d3ca2d46d47552111f2 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 15 Jul 2025 18:56:31 -0400 Subject: [PATCH 415/423] msglist [nfc]: s/_timeOnlyFormat/_timeFormat/ --- lib/widgets/message_list.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 11b87fb228..24b7d2d578 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2011,7 +2011,7 @@ enum MessageTimestampStyle { return DateFormat.yMMMd().format(dateTime); } } - static final _timeOnlyFormat = DateFormat('h:mm aa'); + static final _timeFormat = DateFormat('h:mm aa'); static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); static final _fullFormat = DateFormat.yMMMd().add_jm(); @@ -2031,7 +2031,7 @@ enum MessageTimestampStyle { case dateOnlyRelative: return _formatDateOnlyRelative(asDateTime, now: now, zulipLocalizations: zulipLocalizations); - case timeOnly: return _timeOnlyFormat.format(asDateTime); + case timeOnly: return _timeFormat.format(asDateTime); case lightbox: return _lightboxFormat.format(asDateTime); case full: return _fullFormat.format(asDateTime); } From 91d4ef3114a7f1ff5297d58fb90298c5197bbafc Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 15 Jul 2025 18:59:46 -0400 Subject: [PATCH 416/423] msglist [nfc]: Pull out a _timeFormatWithSeconds helper --- lib/widgets/message_list.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 24b7d2d578..796e070f2e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2012,7 +2012,8 @@ enum MessageTimestampStyle { } } static final _timeFormat = DateFormat('h:mm aa'); - static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); + static final _timeFormatWithSeconds = DateFormat('Hms'); + static final _lightboxFormat = DateFormat.yMMMd().addPattern(_timeFormatWithSeconds.pattern); static final _fullFormat = DateFormat.yMMMd().add_jm(); /// Format a [Message.timestamp] for this mode. From 4d4928ffedafd0e58f330e53b803d4b2f95bb440 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 12:53:10 -0700 Subject: [PATCH 417/423] msglist [nfc]: Add helpers to resolve TwentyFourHourTimeMode We don't use these yet; coming up. --- lib/widgets/message_list.dart | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 796e070f2e..8665b9d3ef 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2011,9 +2011,30 @@ enum MessageTimestampStyle { return DateFormat.yMMMd().format(dateTime); } } - static final _timeFormat = DateFormat('h:mm aa'); - static final _timeFormatWithSeconds = DateFormat('Hms'); - static final _lightboxFormat = DateFormat.yMMMd().addPattern(_timeFormatWithSeconds.pattern); + + static final _timeFormat12 = DateFormat('h:mm aa'); + static final _timeFormat24 = DateFormat('Hm'); + static final _timeFormatLocaleDefault = DateFormat('jm'); + static final _timeFormat12WithSeconds = DateFormat('h:mm:ss aa'); + static final _timeFormat24WithSeconds = DateFormat('Hms'); + static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms'); + + // ignore: unused_element + static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) { + TwentyFourHourTimeMode.twelveHour => _timeFormat12, + TwentyFourHourTimeMode.twentyFourHour => _timeFormat24, + TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault, + }; + + // ignore: unused_element + static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) { + TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds, + TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds, + TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds, + }; + + static final _lightboxFormat = + DateFormat.yMMMd().addPattern(_timeFormat24WithSeconds.pattern); static final _fullFormat = DateFormat.yMMMd().add_jm(); /// Format a [Message.timestamp] for this mode. @@ -2032,7 +2053,7 @@ enum MessageTimestampStyle { case dateOnlyRelative: return _formatDateOnlyRelative(asDateTime, now: now, zulipLocalizations: zulipLocalizations); - case timeOnly: return _timeFormat.format(asDateTime); + case timeOnly: return _timeFormat12.format(asDateTime); case lightbox: return _lightboxFormat.format(asDateTime); case full: return _fullFormat.format(asDateTime); } From 736dc4d3f1f33ad3c116947a06bd58a2cb4413f7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 12:55:29 -0700 Subject: [PATCH 418/423] msglist [nfc]: Use a helper field we just added --- lib/widgets/message_list.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 8665b9d3ef..7c2c830a9b 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2035,7 +2035,8 @@ enum MessageTimestampStyle { static final _lightboxFormat = DateFormat.yMMMd().addPattern(_timeFormat24WithSeconds.pattern); - static final _fullFormat = DateFormat.yMMMd().add_jm(); + static final _fullFormat = + DateFormat.yMMMd().addPattern(_timeFormatLocaleDefault.pattern); /// Format a [Message.timestamp] for this mode. // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi) From 92bae685edc1e664565a9fc8b0532c4f863fd88d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 13:17:44 -0700 Subject: [PATCH 419/423] msglist: Follow user_settings.twenty_four_hour_time for message timestamps Fixes-partly: #1015 --- lib/widgets/message_list.dart | 22 ++++---- test/widgets/lightbox_test.dart | 6 +-- test/widgets/message_list_test.dart | 79 ++++++++++++++++++----------- 3 files changed, 65 insertions(+), 42 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 7c2c830a9b..e3b1d69d6e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2019,25 +2019,18 @@ enum MessageTimestampStyle { static final _timeFormat24WithSeconds = DateFormat('Hms'); static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms'); - // ignore: unused_element static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) { TwentyFourHourTimeMode.twelveHour => _timeFormat12, TwentyFourHourTimeMode.twentyFourHour => _timeFormat24, TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault, }; - // ignore: unused_element static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) { TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds, TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds, TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds, }; - static final _lightboxFormat = - DateFormat.yMMMd().addPattern(_timeFormat24WithSeconds.pattern); - static final _fullFormat = - DateFormat.yMMMd().addPattern(_timeFormatLocaleDefault.pattern); - /// Format a [Message.timestamp] for this mode. // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi) String? format( @@ -2054,9 +2047,18 @@ enum MessageTimestampStyle { case dateOnlyRelative: return _formatDateOnlyRelative(asDateTime, now: now, zulipLocalizations: zulipLocalizations); - case timeOnly: return _timeFormat12.format(asDateTime); - case lightbox: return _lightboxFormat.format(asDateTime); - case full: return _fullFormat.format(asDateTime); + case timeOnly: + return _resolveTimeFormat(twentyFourHourTimeMode).format(asDateTime); + case lightbox: + return DateFormat + .yMMMd() + .addPattern(_resolveTimeFormatWithSeconds(twentyFourHourTimeMode).pattern) + .format(asDateTime); + case full: + return DateFormat + .yMMMd() + .addPattern(_resolveTimeFormat(twentyFourHourTimeMode).pattern) + .format(asDateTime); } } } diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index fc013173e7..a7f6240852 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -382,12 +382,12 @@ void main() { 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'); + checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 11:12:24 PM'); 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'); + checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 11:12:24 PM'); debugNetworkImageHttpClientProvider = null; }); @@ -400,7 +400,7 @@ void main() { await setupPage(tester, message: message, thumbnailUrl: null, users: []); check(store.getUser(sender.userId)).isNull(); - checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 23:12:24'); + checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 11:12:24 PM'); debugNetworkImageHttpClientProvider = null; }); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 43aa4ac79e..3636861a2e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1641,55 +1641,76 @@ void main() { group('MessageTimestampStyle', () { void doTests( MessageTimestampStyle style, - List<(String timestampStr, String? expected)> cases, { + List<( + String timestampStr, + String? expectedTwelveHour, + String? expectedTwentyFourHour, + )> cases, { DateTime? now, }) { now ??= DateTime.parse("2023-01-10 12:00"); - for (final (timestampStr, expected) in cases) { - test('${style.name}: $timestampStr returns $expected', () { - addTearDown(testBinding.reset); - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - - withClock(Clock.fixed(now!), () { - final timestamp = DateTime.parse(timestampStr) - .millisecondsSinceEpoch ~/ 1000; - final result = style.format( - timestamp, - now: testBinding.utcNow().toLocal(), - twentyFourHourTimeMode: TwentyFourHourTimeMode.localeDefault, - zulipLocalizations: zulipLocalizations); - check(result).equals(expected); + for (final (timestampStr, expectedTwelveHour, expectedTwentyFourHour) in cases) { + for (final mode in TwentyFourHourTimeMode.values) { + final expected = switch (mode) { + TwentyFourHourTimeMode.twelveHour => expectedTwelveHour, + TwentyFourHourTimeMode.twentyFourHour => expectedTwentyFourHour, + // This expectation will hold as long as we're always using the + // default locale, en_US, which uses the twelve-hour format. + // TODO(#1727) test with other locales + TwentyFourHourTimeMode.localeDefault => expectedTwelveHour, + }; + + test('${style.name} in ${mode.name}: $timestampStr returns $expected', () { + addTearDown(testBinding.reset); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + withClock(Clock.fixed(now!), () { + final timestamp = DateTime.parse(timestampStr) + .millisecondsSinceEpoch ~/ 1000; + final result = style.format( + timestamp, + now: testBinding.utcNow().toLocal(), + twentyFourHourTimeMode: mode, + zulipLocalizations: zulipLocalizations); + check(result).equals(expected); + }); }); - }); + } } } for (final style in MessageTimestampStyle.values) { switch (style) { case MessageTimestampStyle.none: - doTests(style, [('2023-01-10 12:00', null)]); + doTests(style, [('2023-01-10 12:00', null, null)]); case MessageTimestampStyle.dateOnlyRelative: final zulipLocalizations = GlobalLocalizations.zulipLocalizations; doTests(style, now: DateTime.parse("2023-01-10 12:00"), [ - ("2023-01-10 12:00", zulipLocalizations.today), - ("2023-01-10 00:00", zulipLocalizations.today), - ("2023-01-10 23:59", zulipLocalizations.today), - ("2023-01-09 23:59", zulipLocalizations.yesterday), - ("2023-01-09 00:00", zulipLocalizations.yesterday), - ("2023-01-08 00:00", "Jan 8"), - ("2022-12-31 00:00", "Dec 31, 2022"), + ("2023-01-10 12:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-10 00:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-10 23:59", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-09 23:59", zulipLocalizations.yesterday, zulipLocalizations.yesterday), + ("2023-01-09 00:00", zulipLocalizations.yesterday, zulipLocalizations.yesterday), + ("2023-01-08 00:00", "Jan 8", "Jan 8"), + ("2022-12-31 00:00", "Dec 31, 2022", "Dec 31, 2022"), // Future times - ("2023-01-10 19:00", zulipLocalizations.today), - ("2023-01-11 00:00", "Jan 11, 2023"), + ("2023-01-10 19:00", zulipLocalizations.today, zulipLocalizations.today), + ("2023-01-11 00:00", "Jan 11, 2023", "Jan 11, 2023"), ]); case MessageTimestampStyle.timeOnly: - doTests(style, [('2023-01-10 12:00', '12:00 PM')]); + doTests(style, [('2023-01-10 12:00', '12:00 PM', '12:00')]); case MessageTimestampStyle.lightbox: - doTests(style, [('2023-01-10 12:00', 'Jan 10, 2023 12:00:00')]); + doTests(style, + [('2023-01-10 12:00', + 'Jan 10, 2023 12:00:00 PM', + 'Jan 10, 2023 12:00:00')]); case MessageTimestampStyle.full: - doTests(style, [('2023-01-10 12:00', 'Jan 10, 2023 12:00 PM')]); + doTests(style, + [('2023-01-10 12:00', + 'Jan 10, 2023 12:00 PM', + 'Jan 10, 2023 12:00')]); } } }); From e120e00d666fed2f801ab38ad0522d34776baada Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 19:59:03 -0700 Subject: [PATCH 420/423] content: Follow user_settings.twenty_four_hour_time in global times Fixes #1015. --- lib/widgets/content.dart | 17 ++++++++-- test/widgets/content_test.dart | 60 ++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5d6dfa5084..5851222a1c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1304,13 +1304,26 @@ class GlobalTime extends StatelessWidget { final GlobalTimeNode node; final TextStyle ambientTextStyle; - static final _dateFormat = intl.DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date + static final _format12 = + intl.DateFormat('EEE, MMM d, y').addPattern('h:mm aa', ', '); + static final _format24 = + intl.DateFormat('EEE, MMM d, y').addPattern('Hm', ', '); + static final _formatLocaleDefault = + intl.DateFormat('EEE, MMM d, y').addPattern('jm', ', '); @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final twentyFourHourTimeMode = store.userSettings.twentyFourHourTime; // Design taken from css for `.rendered_markdown & time` in web, // see zulip:web/styles/rendered_markdown.css . - final text = _dateFormat.format(node.datetime.toLocal()); + // TODO(i18n): localize; see plan with ffi in #45 + final format = switch (twentyFourHourTimeMode) { + TwentyFourHourTimeMode.twelveHour => _format12, + TwentyFourHourTimeMode.twentyFourHour => _format24, + TwentyFourHourTimeMode.localeDefault => _formatLocaleDefault, + }; + final text = format.format(node.datetime.toLocal()); final contentTheme = ContentTheme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 0075f50b11..2b7eb45180 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -7,6 +7,8 @@ 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/core.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/content.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/settings.dart'; @@ -118,9 +120,13 @@ Widget plainContent(String html) { Future prepareContent(WidgetTester tester, Widget child, { List navObservers = const [], bool wrapWithPerAccountStoreWidget = false, + InitialSnapshot? initialSnapshot, }) async { if (wrapWithPerAccountStoreWidget) { - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + initialSnapshot ??= eg.initialSnapshot(); + await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); + } else { + assert(initialSnapshot == null); } addTearDown(testBinding.reset); @@ -598,10 +604,12 @@ void main() { Future checkFontSizeRatio(WidgetTester tester, { required String targetHtml, required TargetFontSizeFinder targetFontSizeFinder, + bool wrapWithPerAccountStoreWidget = false, }) async { - await prepareContent(tester, plainContent( - '

header-plain $targetHtml

\n' - '

paragraph-plain $targetHtml

')); + await prepareContent(tester, wrapWithPerAccountStoreWidget: wrapWithPerAccountStoreWidget, + plainContent( + '

header-plain $targetHtml

\n' + '

paragraph-plain $targetHtml

')); final headerRootSpan = tester.renderObject(find.textContaining('header')).text; final headerPlainStyle = mergedStyleOfSubstring(headerRootSpan, 'header-plain '); @@ -1071,16 +1079,52 @@ void main() { // the timezone of the environment running these tests. Accept here a wide // range of times. See comments in "show dates" test in // `test/widgets/message_list_test.dart`. - final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$'); + final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d(?: [AP]M)?$'); + final renderedTextRegexpTwelveHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$'); + final renderedTextRegexpTwentyFourHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d$'); + + Future prepare( + WidgetTester tester, + [TwentyFourHourTimeMode twentyFourHourTimeMode = TwentyFourHourTimeMode.localeDefault] + ) async { + final initialSnapshot = eg.initialSnapshot() + ..userSettings.twentyFourHourTime = twentyFourHourTimeMode; + await prepareContent(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, + initialSnapshot: initialSnapshot, + plainContent('

$timeSpanHtml

')); + } testWidgets('smoke', (tester) async { - await prepareContent(tester, plainContent('

$timeSpanHtml

')); + await prepare(tester); tester.widget(find.textContaining(renderedTextRegexp)); }); + testWidgets('TwentyFourHourTimeMode.twelveHour', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.twelveHour); + check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne(); + }); + + testWidgets('TwentyFourHourTimeMode.twentyFourHour', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.twentyFourHour); + check(find.textContaining(renderedTextRegexpTwentyFourHour)).findsOne(); + }); + + testWidgets('TwentyFourHourTimeMode.localeDefault', (tester) async { + await prepare(tester, TwentyFourHourTimeMode.localeDefault); + // This expectation holds as long as we're always formatting in en_US, + // the default locale, which uses the twelve-hour format. + // TODO(#1727) follow the actual locale; test with different locales + check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne(); + }); + void testIconAndTextSameColor(String description, String html) { testWidgets('clock icon and text are the same color: $description', (tester) async { - await prepareContent(tester, plainContent(html)); + await prepareContent(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, + plainContent(html)); final icon = tester.widget( find.descendant(of: find.byType(GlobalTime), @@ -1100,6 +1144,8 @@ void main() { group('maintains font-size ratio with surrounding text', () { Future doCheck(WidgetTester tester, double Function(GlobalTime widget) sizeFromWidget) async { await checkFontSizeRatio(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, targetHtml: '', targetFontSizeFinder: (rootSpan) { late final double result; From 55e7067bd9d3858f49ba553e41276120eea78dab Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Fri, 25 Jul 2025 03:45:50 +0200 Subject: [PATCH 421/423] l10n: Update translations from Weblate. --- assets/l10n/app_fr.arb | 1 + assets/l10n/app_zh_Hant_TW.arb | 580 ++++++++++- lib/generated/l10n/zulip_localizations.dart | 5 + .../l10n/zulip_localizations_fr.dart | 897 ++++++++++++++++++ .../l10n/zulip_localizations_zh.dart | 396 +++++++- 5 files changed, 1875 insertions(+), 4 deletions(-) create mode 100644 assets/l10n/app_fr.arb create mode 100644 lib/generated/l10n/zulip_localizations_fr.dart diff --git a/assets/l10n/app_fr.arb b/assets/l10n/app_fr.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_fr.arb @@ -0,0 +1 @@ +{} diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb index b05167206a..076a7b452a 100644 --- a/assets/l10n/app_zh_Hant_TW.arb +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -29,7 +29,7 @@ "@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." }, @@ -363,7 +363,7 @@ "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." }, - "composeBoxTopicHintText": "話題", + "composeBoxTopicHintText": "議題", "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." }, @@ -598,5 +598,581 @@ "searchMessagesClearButtonTooltip": "清除", "@searchMessagesClearButtonTooltip": { "description": "Tooltip for the 'x' button in the search text field." + }, + "upgradeWelcomeDialogMessage": "您將在更快、更流暢的版本中享受熟悉的體驗。", + "@upgradeWelcomeDialogMessage": { + "description": "Message text 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." + }, + "logOutConfirmationDialogMessage": "要在未來使用此帳號,您將需要重新輸入您組織的網址和您的帳號資訊。", + "@logOutConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for logging out." + }, + "errorCouldNotShowUserProfile": "無法顯示使用者設定檔。", + "@errorCouldNotShowUserProfile": { + "description": "Message that appears on the user profile page when the profile cannot be shown." + }, + "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." + }, + "errorCouldNotFetchMessageSource": "無法取得訊息來源。", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "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" + } + } + }, + "errorFilesTooLarge": "{num, plural, =1{檔案} 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" + } + } + }, + "errorFilesTooLargeTitle": "{num, plural, =1{檔案} other{檔案}}太大", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "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." + }, + "errorMessageEditNotSaved": "訊息沒有儲存", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, + "errorServerMessage": "伺服器回應:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": { + "type": "String", + "example": "Invalid format" + } + } + }, + "errorConnectingToServerShort": "連接 Zulip 時發生錯誤。重試中…", + "@errorConnectingToServerShort": { + "description": "Short error message for a generic unknown error connecting to the server." + }, + "errorConnectingToServerDetails": "連接 Zulip {serverUrl} 時發生錯誤。將重試:\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" + } + } + }, + "errorHandlingEventTitle": "處理 Zulip 事件時發生錯誤。重新連線中…", + "@errorHandlingEventTitle": { + "description": "Error title on failing to handle a Zulip server event." + }, + "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')" + } + } + }, + "errorBannerDeactivatedDmLabel": "您無法向已停用的使用者發送訊息。", + "@errorBannerDeactivatedDmLabel": { + "description": "Label text for error banner when sending a message to one or multiple deactivated users." + }, + "errorBannerCannotPostInChannelLabel": "您沒有權限在此頻道發佈訊息。", + "@errorBannerCannotPostInChannelLabel": { + "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." + }, + "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.)" + }, + "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." + }, + "newDmSheetComposeButtonLabel": "編寫", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." + }, + "newDmSheetNoUsersFound": "找不到使用者", + "@newDmSheetNoUsersFound": { + "description": "Message shown in the new DM sheet when no users match the search." + }, + "composeBoxSelfDmContentHint": "記下些什麼", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "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." + }, + "composeBoxEnterTopicOrSkipHintText": "輸入議題(留空則使用「{defaultTopicName}」)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": { + "type": "String", + "example": "general chat" + } + } + }, + "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." + }, + "dmsWithYourselfPageTitle": "私訊給自己", + "@dmsWithYourselfPageTitle": { + "description": "Message list page title for a DM group that only includes yourself." + }, + "messageListGroupYouAndOthers": "您與 {others}", + "@messageListGroupYouAndOthers": { + "description": "Message list recipient header for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "dmsWithOthersPageTitle": "與 {others} 的私訊", + "@dmsWithOthersPageTitle": { + "description": "Message list page title for a DM group with others.", + "placeholders": { + "others": { + "type": "String", + "example": "Alice, Bob" + } + } + }, + "emptyMessageList": "這裡沒有訊息。", + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "emptyMessageListSearch": "沒有搜尋結果。", + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "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." + }, + "contentValidationErrorEmpty": "您沒有要發送的內容!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "discardDraftConfirmationDialogTitle": "要捨棄您正在編寫的訊息嗎?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftForEditConfirmationDialogMessage": "當您編輯訊息時,編輯框中原有的內容將被捨棄。", + "@discardDraftForEditConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." + }, + "discardDraftForOutboxConfirmationDialogMessage": "當您還原未發送的訊息時,編輯框中原有的內容將被捨棄。", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, 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." + }, + "errorDialogContinue": "OK", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "snackBarDetails": "詳細資訊", + "@snackBarDetails": { + "description": "Button label for snack bar button that opens a dialog with more details." + }, + "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." + }, + "loginMethodDivider": "或", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "topicValidationErrorTooLong": "議題長度不得超過 60 個字元。", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "topicValidationErrorMandatoryButEmpty": "此組織要求必須填寫議題。", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, + "errorServerVersionUnsupportedMessage": "{url} 執行的 Zulip Server 為 {zulipVersion},此版本已不受支援。最低支援版本為 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" + } + } + }, + "errorInvalidApiKeyMessage": "您在 {url} 的帳號無法通過驗證。請重新登入或使用其他帳號。", + "@errorInvalidApiKeyMessage": { + "description": "Error message in the dialog for invalid API key.", + "placeholders": { + "url": { + "type": "String", + "example": "http://chat.example.com/" + } + } + }, + "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" + } + } + }, + "errorRequestFailed": "網路請求失敗:HTTP 狀態碼為 {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "httpStatus": { + "type": "int", + "example": "500" + } + } + }, + "serverUrlValidationErrorUnsupportedScheme": "伺服器 URL 必須以 http:// 或 https:// 開頭。", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, + "markAsReadComplete": "已標為已讀:{num, plural, =1{1 則訊息} other{{num} 則訊息}}。", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "markAsReadInProgress": "正在標記訊息為已讀…", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "errorMarkAsReadFailedTitle": "標記為已讀失敗", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, + "markAsUnreadComplete": "已標為未讀:{num, plural, =1{1 則訊息} other{{num} 則訊息}}。", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, + "errorMarkAsUnreadFailedTitle": "標記為未讀失敗", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, + "invisibleMode": "隱身模式", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "啟用隱身模式時發生錯誤。請再試一次。", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "關閉隱身模式時發生錯誤。請再試一次。", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "userRoleUnknown": "未知", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + }, + "inboxEmptyPlaceholder": "您的收件匣中沒有未讀訊息。請使用下方按鈕檢視整合訊息流或頻道清單。", + "@inboxEmptyPlaceholder": { + "description": "Centered text on the 'Inbox' 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." + }, + "starredMessagesPageTitle": "已加星號的訊息", + "@starredMessagesPageTitle": { + "description": "Page title for the 'Starred messages' message view." + }, + "channelsEmptyPlaceholder": "您尚未訂閱任何頻道。", + "@channelsEmptyPlaceholder": { + "description": "Centered text on the 'Channels' page saying that there is no content to show." + }, + "mainMenuMyProfile": "我的設定檔", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, + "notifGroupDmConversationLabel": "{senderFullName} 傳送給您和 {numOthers, plural, =1{1 位其他對象、} other{{numOthers} 位其他對象}}", + "@notifGroupDmConversationLabel": { + "description": "Label for a group DM conversation notification.", + "placeholders": { + "senderFullName": { + "type": "String", + "example": "Alice" + }, + "numOthers": { + "type": "int", + "example": "4" + } + } + }, + "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." + }, + "onePersonTyping": "{typist} 正在輸入…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": { + "type": "String", + "example": "Alice" + } + } + }, + "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." + }, + "wildcardMentionAll": "全部", + "@wildcardMentionAll": { + "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM 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." + }, + "wildcardMentionStreamDescription": "通知串流", + "@wildcardMentionStreamDescription": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers." + }, + "wildcardMentionAllDmDescription": "通知收件人", + "@wildcardMentionAllDmDescription": { + "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message." + }, + "messageIsEditedLabel": "已編輯", + "@messageIsEditedLabel": { + "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageIsMovedLabel": "已移動", + "@messageIsMovedLabel": { + "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "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.)" + }, + "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" + } + } + }, + "openLinksWithInAppBrowser": "使用應用程式內建瀏覽器開啟連結", + "@openLinksWithInAppBrowser": { + "description": "Label for toggling setting to open links with in-app browser" + }, + "pollWidgetQuestionMissing": "沒有問題。", + "@pollWidgetQuestionMissing": { + "description": "Text to display for a poll when the question is missing" + }, + "pollWidgetOptionsMissing": "此投票尚未有任何選項。", + "@pollWidgetOptionsMissing": { + "description": "Text to display for a poll when it has no options" + }, + "initialAnchorSettingTitle": "開啟訊息串於", + "@initialAnchorSettingTitle": { + "description": "Title 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." + }, + "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." + }, + "markReadOnScrollSettingAlways": "總是", + "@markReadOnScrollSettingAlways": { + "description": "Label for a value of setting controlling which message-list views should mark read on scroll." } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 99b52aced1..9668b50f26 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -8,6 +8,7 @@ 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_fr.dart'; import 'zulip_localizations_it.dart'; import 'zulip_localizations_ja.dart'; import 'zulip_localizations_nb.dart'; @@ -108,6 +109,7 @@ abstract class ZulipLocalizations { Locale('ar'), Locale('de'), Locale('en', 'GB'), + Locale('fr'), Locale('it'), Locale('ja'), Locale('nb'), @@ -1658,6 +1660,7 @@ class _ZulipLocalizationsDelegate 'ar', 'de', 'en', + 'fr', 'it', 'ja', 'nb', @@ -1702,6 +1705,8 @@ ZulipLocalizations lookupZulipLocalizations(Locale locale) { return ZulipLocalizationsDe(); case 'en': return ZulipLocalizationsEn(); + case 'fr': + return ZulipLocalizationsFr(); case 'it': return ZulipLocalizationsIt(); case 'ja': diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart new file mode 100644 index 0000000000..cbc18b6d35 --- /dev/null +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -0,0 +1,897 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'zulip_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for French (`fr`). +class ZulipLocalizationsFr extends ZulipLocalizations { + ZulipLocalizationsFr([String locale = 'fr']) : 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 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'; + + @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 actionSheetOptionQuoteMessage => 'Quote message'; + + @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 emptyMessageList => 'There are no messages here.'; + + @override + String get emptyMessageListSearch => 'No search results.'; + + @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 invisibleMode => 'Invisible mode'; + + @override + String get turnOnInvisibleModeErrorTitle => + 'Error turning on invisible mode. Please try again.'; + + @override + String get turnOffInvisibleModeErrorTitle => + 'Error turning off invisible mode. Please try again.'; + + @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 searchMessagesPageTitle => 'Search'; + + @override + String get searchMessagesHintText => 'Search'; + + @override + String get searchMessagesClearButtonTooltip => 'Clear'; + + @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 topicsButtonTooltip => '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 conversation views, newest message elsewhere'; + + @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'; + + @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 revealButtonLabel => 'Reveal message'; + + @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_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 8f88549383..c44852c041 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -1745,6 +1745,15 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get upgradeWelcomeDialogTitle => '歡迎使用新 Zulip 應用程式!'; + @override + String get upgradeWelcomeDialogMessage => '您將在更快、更流暢的版本中享受熟悉的體驗。'; + + @override + String get upgradeWelcomeDialogLinkText => '查看公告部落格文章!'; + + @override + String get upgradeWelcomeDialogDismiss => '開始吧'; + @override String get chooseAccountPageTitle => '選取帳號'; @@ -1768,6 +1777,10 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get logOutConfirmationDialogTitle => '登出?'; + @override + String get logOutConfirmationDialogMessage => + '要在未來使用此帳號,您將需要重新輸入您組織的網址和您的帳號資訊。'; + @override String get logOutConfirmationDialogConfirmButton => '登出'; @@ -1777,17 +1790,27 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @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 => '話題列表'; + String get actionSheetOptionListOfTopics => '議題列表'; @override String get actionSheetOptionMuteTopic => '靜音話題'; @@ -1857,12 +1880,60 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { 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 個檔案', + one: '檔案', + ); + return '$_temp0超過伺服器 $maxFileUploadSizeMib MiB 的限制,將不會上傳:\n\n$listMessage'; + } + + @override + String errorFilesTooLargeTitle(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '檔案', + one: '檔案', + ); + return '$_temp0太大'; + } + + @override + String get errorLoginInvalidInputTitle => '無效的輸入'; + @override String get errorLoginFailedTitle => '登入失敗'; + @override + String get errorMessageNotSent => '訊息沒有送出'; + + @override + String get errorMessageEditNotSaved => '訊息沒有儲存'; + @override String errorLoginCouldNotConnect(String url) { return '無法連線到伺服器:\n$url'; @@ -1877,6 +1948,31 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @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 '連接 Zulip $serverUrl 時發生錯誤。將重試:\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 => '無法開啟連結'; @@ -1918,21 +2014,62 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @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 => '新增私訊'; @@ -1945,6 +2082,9 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get newDmSheetSearchHintSomeSelected => '增添其他使用者…'; + @override + String get newDmSheetNoUsersFound => '找不到使用者'; + @override String composeBoxDmContentHint(String user) { return '訊息 @$user'; @@ -1953,19 +2093,72 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get composeBoxGroupDmContentHint => '訊息群組'; + @override + String get composeBoxSelfDmContentHint => '記下些什麼'; + @override String composeBoxChannelContentHint(String destination) { return '訊息 $destination'; } @override - String get composeBoxTopicHintText => '話題'; + 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 emptyMessageList => '這裡沒有訊息。'; + + @override + String get emptyMessageListSearch => '沒有搜尋結果。'; + + @override + String get messageListGroupYouWithYourself => '與自己的訊息'; + + @override + String get contentValidationErrorTooLong => '訊息長度不應超過 10000 個字元。'; + + @override + String get contentValidationErrorEmpty => '您沒有要發送的內容!'; + @override String get contentValidationErrorQuoteAndReplyInProgress => '請等待引述完成。'; @@ -1984,18 +2177,33 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get errorDialogLearnMore => '了解更多'; + @override + String get errorDialogContinue => 'OK'; + @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 登入'; @@ -2028,12 +2236,47 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @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 Server 為 $zulipVersion,此版本已不受支援。最低支援版本為 Zulip Server $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 => '無法播放影片。'; @@ -2046,21 +2289,65 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get serverUrlValidationErrorNoUseEmail => '請輸入伺服器網址,而非您的電子郵件。'; + @override + String get serverUrlValidationErrorUnsupportedScheme => + '伺服器 URL 必須以 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 則訊息', + one: '1 則訊息', + ); + 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 則訊息', + one: '1 則訊息', + ); + return '已標為未讀:$_temp0。'; + } + @override String get markAsUnreadInProgress => '正在標註訊息為未讀…'; + @override + String get errorMarkAsUnreadFailedTitle => '標記為未讀失敗'; + @override String get today => '今天'; @override String get yesterday => '昨天'; + @override + String get invisibleMode => '隱身模式'; + + @override + String get turnOnInvisibleModeErrorTitle => '啟用隱身模式時發生錯誤。請再試一次。'; + + @override + String get turnOffInvisibleModeErrorTitle => '關閉隱身模式時發生錯誤。請再試一次。'; + @override String get userRoleOwner => '擁有者'; @@ -2076,6 +2363,9 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @override String get userRoleGuest => '訪客'; + @override + String get userRoleUnknown => '未知'; + @override String get searchMessagesPageTitle => '搜尋'; @@ -2088,45 +2378,119 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @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 topicsButtonTooltip => '話題'; @override String get channelFeedButtonTooltip => '頻道饋給'; + @override + String notifGroupDmConversationLabel(String senderFullName, int numOthers) { + String _temp0 = intl.Intl.pluralLogic( + numOthers, + locale: localeName, + other: '$numOthers 位其他對象', + one: '1 位其他對象、', + ); + 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 => 'channel'; + @override + String get wildcardMentionStream => '串流'; + @override String get wildcardMentionTopic => 'topic'; @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 => '主題'; @@ -2139,12 +2503,40 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @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 markReadOnScrollSettingTitle => '捲動時將訊息標記為已讀'; + + @override + String get markReadOnScrollSettingDescription => '在捲動瀏覽訊息時,是否要自動將其標記為已讀?'; + + @override + String get markReadOnScrollSettingAlways => '總是'; + @override String get experimentalFeatureSettingsPageTitle => '實驗性功能'; From 11ffac283f058542d96f6e10d7518dc83c2338b3 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 24 Jul 2025 21:24:09 -0700 Subject: [PATCH 422/423] version: Sync version and changelog from v30.0.262 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 fd2adb2da6..d35fee742c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,44 @@ ## Unreleased +## 30.0.262 (2025-07-24) + +This release branch includes some experimental changes +not yet merged to the main branch. + + +### Highlights for users + +* Fix "general chat" to show new messages as normal + after opening via a notification. (#1717) +* Set your status emoji and status message. (#198) +* Fix deactivated users appearing in "New DM" screen. (#1743) +* Follow your personal setting for 24-hour or 12-hour time + format. (#1015) +* Translation updates. (PR #1726, PR #1750) + + +### Highlights for developers + +* User-visible changes not described above: + * Avoid showing potentially wrong result if encountering + a KaTeX vlist with unexpected inline style properties. + (c4503b492; revision to PR #1698, for #46) + * Fix double-application of negative margin on KaTeX vlist items. + (64956b8f0; revision to PR #1559, for #46) + * Better semantics on settings radio buttons, for a11y. (#1545) + +* Store and substore refactors: RealmStore; proxy mixins; + move more methods to individual substores. (PR #1736) + +* Resolved in main: #1710, #1712, PR #1698, #1717, PR #1559, #46, + PR #1719, PR #1726, #197, #1545, PR #1736, #1743, #1015, PR #1750 + +* Resolved in the experimental branch: + * #740 via PR #1700 + * #198 via PR #1701 + + ## 30.0.261 (2025-07-09) This release branch includes some experimental changes diff --git a/pubspec.yaml b/pubspec.yaml index a87dc4a4bd..cb108fa57e 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.261+261 +version: 30.0.262+262 environment: # We use a recent version of Flutter from its main channel, and From 077e33c234f02cd4219733ea1caec54eeafd7f0f Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Sat, 26 Jul 2025 10:03:37 +0200 Subject: [PATCH 423/423] l10n: Update translations from Weblate. --- assets/l10n/app_uk.arb | 34 ++++++++++++- assets/l10n/app_zh_Hant_TW.arb | 50 ++++++++++++++++++- .../l10n/zulip_localizations_uk.dart | 19 ++++--- .../l10n/zulip_localizations_zh.dart | 40 ++++++++++++++- 4 files changed, 130 insertions(+), 13 deletions(-) diff --git a/assets/l10n/app_uk.arb b/assets/l10n/app_uk.arb index a83421c686..4dc1c5421c 100644 --- a/assets/l10n/app_uk.arb +++ b/assets/l10n/app_uk.arb @@ -1059,7 +1059,7 @@ "@newDmSheetNoUsersFound": { "description": "Message shown in the new DM sheet when no users match the search." }, - "revealButtonLabel": "Показати повідомлення заглушеного відправника", + "revealButtonLabel": "Показати повідомлення", "@revealButtonLabel": { "description": "Label for the button revealing hidden message from a muted sender in message list." }, @@ -1192,5 +1192,37 @@ "markReadOnScrollSettingConversationsDescription": "Повідомлення будуть автоматично помічатися як прочитані тільки при перегляді окремої теми або особистої бесіди.", "@markReadOnScrollSettingConversationsDescription": { "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "emptyMessageList": "Тут немає повідомлень.", + "@emptyMessageList": { + "description": "Placeholder for some message-list pages when there are no messages." + }, + "emptyMessageListSearch": "Немає результатів пошуку.", + "@emptyMessageListSearch": { + "description": "Placeholder for the 'Search' page when there are no messages." + }, + "invisibleMode": "Невидимий режим", + "@invisibleMode": { + "description": "Label for the 'Invisible mode' switch on the profile page." + }, + "turnOnInvisibleModeErrorTitle": "Помилка ввімкнення режиму невидимості. Спробуйте ще раз.", + "@turnOnInvisibleModeErrorTitle": { + "description": "Error title when turning on invisible mode failed." + }, + "turnOffInvisibleModeErrorTitle": "Помилка вимкнення режиму невидимості. Спробуйте ще раз.", + "@turnOffInvisibleModeErrorTitle": { + "description": "Error title when turning off invisible mode failed." + }, + "searchMessagesPageTitle": "Пошук", + "@searchMessagesPageTitle": { + "description": "Page title for the 'Search' message view." + }, + "searchMessagesHintText": "Пошук", + "@searchMessagesHintText": { + "description": "Hint text for the message search text field." + }, + "searchMessagesClearButtonTooltip": "Очистити", + "@searchMessagesClearButtonTooltip": { + "description": "Tooltip for the 'x' button in the search text field." } } diff --git a/assets/l10n/app_zh_Hant_TW.arb b/assets/l10n/app_zh_Hant_TW.arb index 076a7b452a..d784114d3a 100644 --- a/assets/l10n/app_zh_Hant_TW.arb +++ b/assets/l10n/app_zh_Hant_TW.arb @@ -459,7 +459,7 @@ "@emojiReactionsMore": { "description": "Label for a button opening the emoji picker." }, - "errorSharingFailed": "分享失敗。", + "errorSharingFailed": "分享失敗", "@errorSharingFailed": { "description": "Error message when sharing a message failed." }, @@ -1174,5 +1174,53 @@ "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." + }, + "markReadOnScrollSettingConversationsDescription": "只有在檢視單一議題或私人訊息對話時,訊息才會自動標記為已讀。", + "@markReadOnScrollSettingConversationsDescription": { + "description": "Description for a value of setting controlling which message-list views should mark read on scroll." + }, + "experimentalFeatureSettingsWarning": "這些選項啟用的功能仍在開發中,尚未完善。它們可能無法正常運作,且可能導致應用程式其他部分出現問題。\n\n這些設定的目的是供參與 Zulip 開發的人員進行試驗使用。", + "@experimentalFeatureSettingsWarning": { + "description": "Warning text on settings page for experimental, in-development features" + }, + "errorNotificationOpenAccountNotFound": "找不到與此通知相關聯的帳號。", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" + }, + "errorReactionAddingFailedTitle": "新增表情反應失敗", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "移除表情反應失敗", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "noEarlierMessages": "沒有更早的訊息", + "@noEarlierMessages": { + "description": "Text to show at the start of a message list if there are no earlier messages." + }, + "revealButtonLabel": "顯示訊息", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted sender in message list." + }, + "scrollToBottomTooltip": "捲動至底部", + "@scrollToBottomTooltip": { + "description": "Tooltip for button to scroll to bottom." + }, + "appVersionUnknownPlaceholder": "(…)", + "@appVersionUnknownPlaceholder": { + "description": "Placeholder to show in place of the app version when it is unknown." + }, + "zulipAppTitle": "Zulip", + "@zulipAppTitle": { + "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." } } diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6799942531..3f8ddd650a 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -446,10 +446,10 @@ class ZulipLocalizationsUk extends ZulipLocalizations { } @override - String get emptyMessageList => 'There are no messages here.'; + String get emptyMessageList => 'Тут немає повідомлень.'; @override - String get emptyMessageListSearch => 'No search results.'; + String get emptyMessageListSearch => 'Немає результатів пошуку.'; @override String get messageListGroupYouWithYourself => 'Повідомлення з собою'; @@ -650,15 +650,15 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get yesterday => 'Учора'; @override - String get invisibleMode => 'Invisible mode'; + String get invisibleMode => 'Невидимий режим'; @override String get turnOnInvisibleModeErrorTitle => - 'Error turning on invisible mode. Please try again.'; + 'Помилка ввімкнення режиму невидимості. Спробуйте ще раз.'; @override String get turnOffInvisibleModeErrorTitle => - 'Error turning off invisible mode. Please try again.'; + 'Помилка вимкнення режиму невидимості. Спробуйте ще раз.'; @override String get userRoleOwner => 'Власник'; @@ -679,13 +679,13 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get userRoleUnknown => 'Невідомо'; @override - String get searchMessagesPageTitle => 'Search'; + String get searchMessagesPageTitle => 'Пошук'; @override - String get searchMessagesHintText => 'Search'; + String get searchMessagesHintText => 'Пошук'; @override - String get searchMessagesClearButtonTooltip => 'Clear'; + String get searchMessagesClearButtonTooltip => 'Очистити'; @override String get inboxPageTitle => 'Вхідні'; @@ -898,8 +898,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get noEarlierMessages => 'Немає попередніх повідомлень'; @override - String get revealButtonLabel => - 'Показати повідомлення заглушеного відправника'; + String get revealButtonLabel => 'Показати повідомлення'; @override String get mutedUser => 'Заглушений користувач'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index c44852c041..017e7487cf 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -1994,7 +1994,7 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { String get errorUnfollowTopicFailed => '無法取消跟隨話題'; @override - String get errorSharingFailed => '分享失敗。'; + String get errorSharingFailed => '分享失敗'; @override String get errorStarMessageFailedTitle => '無法收藏訊息'; @@ -2537,18 +2537,56 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh { @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這些設定的目的是供參與 Zulip 開發的人員進行試驗使用。'; + @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 revealButtonLabel => '顯示訊息'; + @override String get mutedUser => '已靜音的使用者'; + + @override + String get scrollToBottomTooltip => '捲動至底部'; + + @override + String get appVersionUnknownPlaceholder => '(…)'; + + @override + String get zulipAppTitle => 'Zulip'; }